The ResizeObserver API for Element Size Detection and Container Query Polyfills
Use ResizeObserver to detect element size changes and drive CSS breakpoints via custom properties, and know when native container queries eliminate the JavaScript dependency and the main-thread cost.
When You Still Need JavaScript For Size Changes
When you need to react to an element changing size, ResizeObserver is still the only JavaScript API that does it cleanly. But native container queries have made it a fallback rather than the default. You reach for ResizeObserver as a container query JavaScript fallback when the element you need to respond to cannot itself be a size container. Either it already has a container-type declaration for a different purpose, or you must set a custom property that other elements consume. Here is the honest cost: a ResizeObserver callback runs on the main thread after layout. It is far cheaper than the old window resize listener with getBoundingClientRect() that fired on every pixel change. But it still costs you JavaScript execution time that a pure CSS container query never spends.
The Pattern This Replaces
The pattern being replaced is the one every senior front-end developer wrote between 2015 and 2022: a window resize event listener, a getBoundingClientRect() call on the element, a manual width comparison, and a CSS class or inline style flip. That approach fired on every viewport change, whether the element moved or not. It caused layout thrashing when you read layout values and then wrote styles in the same frame. ResizeObserver fixed the thrashing by batching observations into a single callback after layout. It did not remove the main-thread cost. The ResizeObserver specification is explicit: the callback is invoked after layout, on the main thread, and any style change you make inside it triggers another layout pass. Native container queries, by contrast, are evaluated during style resolution, with no JavaScript overhead at all.
What ResizeObserver Actually Gives You That CSS Cannot
ResizeObserver gives you three measurements per entry: contentRect (the old draft API, still present), contentBoxSize, and borderBoxSize. The contentBoxSize and borderBoxSize are arrays. For most elements each array has exactly one item; the length changes only for elements in a fragmented context, like a multicolumn container that splits an element across fragments. The key point for your layout: contentBoxSize excludes padding and border, while borderBoxSize includes both. If you are building a component where the padding changes at a breakpoint, and you need the outer dimension to position a fixed overlay, borderBoxSize is the one you want. There is also devicePixelContentBoxSize, which reports the size in device pixels rather than CSS pixels. That is Chromium-only today, so verify it before you rely on it.
ResizeObserver Element Size Change Callback in Practice
The Pre-Container-Queries Technique
Here is the pre-container-queries pattern: a ResizeObserver that watches an element, reads its contentBoxSize, and writes a CSS custom property that downstream CSS uses as a breakpoint. This is the technique that made component-level responsiveness possible before container queries shipped.
// Observe an element and set --component-width on it.
const target = document.querySelector('.widget');
const observer = new ResizeObserver((entries, observer) => {
for (const entry of entries) {
const width = entry.contentBoxSize[0].inlineSize;
target.style.setProperty('--component-width', `${width}px`);
}
});
observer.observe(target);
/* CSS consumes the custom property as a breakpoint. */
.widget {
container-type: normal; /* not a size container, so this works */
--component-width: 100%;
padding: 1rem;
}
.widget .child {
display: grid;
grid-template-columns: 1fr;
}
/* The real logic: compare the observed width. */
.widget {
--is-narrow: 0;
}
/* Use calc to compare: if --component-width is narrow, set a flag. */
.widget {
--narrow-flag: max(0, min(1, (500 - var(--component-width)) * 1000));
}
/* This is ugly; the point is it works without container queries. */
The custom property calc trick works but is fragile. You are using calc() to coerce a boolean out of a subtraction. That sort of cleverness breaks when someone changes the unit. This is why container queries replaced it: the CSS is unreadable.
ResizeObserver vs Container Queries CSS: The Same Component, No JavaScript
The same component, rewritten with a native size container query, needs zero JavaScript. The container-type declaration on the parent establishes the query container, and the @container rule responds to its inline-size. This is the migration path: if you control the element you are measuring, and nothing else needs it to be a container, use container-type: inline-size.
Card Title
Body text that reflows based on container width.
.card-container {
container-type: inline-size;
}
.card {
display: grid;
grid-template-columns: 1fr;
gap: 0.5rem;
}
@container (min-width: 500px) {
.card {
grid-template-columns: 2fr 1fr;
}
}
@container (min-width: 800px) {
.card {
grid-template-columns: 1fr 1fr 1fr;
}
}
The @container rule does not invoke JavaScript, does not read a ResizeObserverEntry, and does not touch the main thread. The browser evaluates the container's size during style resolution and applies the matching declarations. This is the performance advantage to cite when a colleague argues that a ResizeObserver is "just a few lines". It is, but those lines run on the main thread after every layout. A container query runs during layout with zero JavaScript cost.
ResizeObserver Custom Property Size Breakpoint: When You Cannot Use Container Queries
There is one case where the ResizeObserver custom property pattern is not a legacy fallback but the only option: the element you need to measure already has a container-type declaration for a different purpose. A container cannot be its own query subject. If you set container-type: inline-size on an element to query its children, you cannot also use @container to query that same element's size. The query would be circular. The specification forbids it, and the browser ignores it.
Here is a complete sample that measures borderBoxSize on an element that is already a container for its children, so you must use ResizeObserver to respond to its outer dimension.
This panel is a container for its children.
const panel = document.getElementById('panel');
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const outerWidth = entry.borderBoxSize[0].inlineSize;
panel.style.setProperty('--panel-outer-width', `${outerWidth}px`);
panel.style.setProperty('--panel-is-narrow', outerWidth < 600 ? '1' : '0');
}
});
ro.observe(panel);
.panel {
container-type: inline-size;
padding: 1rem;
border: 2px solid #ccc;
--panel-outer-width: 100%;
}
.panel + .sidebar {
margin-left: calc(var(--panel-outer-width) * 0.1);
}
This is the niche where ResizeObserver will remain relevant for years. You cannot query the container that you are also querying from. The custom property bridge is the only way to expose that element's size to the rest of the page.
ResizeObserver Performance Main Thread: What the Callback Actually Costs
The performance question is not whether ResizeObserver is slow. It is far faster than the old window resize listener. The problem is that it runs on the main thread. The ResizeObserver specification (W3C Resize Observer Level 1) states that the callback is delivered after layout, and any style change you make in that callback queues another layout. That second layout is the hidden cost. If you write to a custom property that affects the observed element's own size, you create a loop: the observer fires, you change a style, the element resizes, the observer fires again. The specification protects you from infinite recursion by throwing an error, ResizeObserver loop completed with undelivered notifications. That error means your page is doing work it should not be doing.
Fix It With requestAnimationFrame
The mitigation is to wrap your dimension-dependent style changes in requestAnimationFrame. This decouples the read (the ResizeObserver entry) from the write (the style update), so the browser can coalesce multiple changes into one layout pass. It does not move the work off the main thread, but it reduces the number of layout passes. Here is the corrected version:
const ro = new ResizeObserver((entries) => {
const width = entries[0].contentBoxSize[0].inlineSize;
requestAnimationFrame(() => {
target.style.setProperty('--component-width', `${width}px`);
});
});
This is the pattern to copy. It prevents the loop, and it makes the cost predictable: one layout per frame, not one per resize event. Native container queries never need this dance, because the browser evaluates them during style resolution with no JavaScript in the loop.
ResizeObserver vs Container Queries: When to Use Each One
The decision rule: if the element you are responding to can be a size container query, use @container. That means the element has no other container-type declaration, and you are styling its own children based on its width. If you need to set a custom property that siblings or ancestors read, or if the element is already a container for a different purpose, use ResizeObserver with borderBoxSize or contentBoxSize. This is the ResizeObserver container query JavaScript fallback in its current, correct form: a targeted tool for the few cases CSS cannot express.
The Failure Case: ResizeObserver Loop and How to Avoid It
The most common ResizeObserver failure is the infinite loop. You will see it as an error in the console: ResizeObserver loop completed with undelivered notifications. It happens when you read a size in the callback and then immediately write a style that changes that size on the same element. The classic mistake is setting width: 100% on the observed element inside the callback, which changes the measured size and triggers another callback. The requestAnimationFrame wrap fixes most cases. Also check whether you really need to observe the element whose size you are changing. If you are observing a wrapper and styling its child, the loop is less likely, but still possible if the child's size feeds back into the wrapper.
The second failure mode is forgetting to call disconnect() when the observed element is removed from the DOM. The ResizeObserver holds a reference to the target. If you do not disconnect, the callback keeps firing on a detached element, leaking memory. The fix is to use a MutationObserver to detect removal, or to call disconnect() in the component's cleanup function. In React, that is the useEffect cleanup; in vanilla JavaScript, it is the place where you remove the element from the page.
What Container Queries Do That ResizeObserver Cannot
Container queries give you three things ResizeObserver never will: no main-thread JavaScript, automatic evaluation during style resolution, and the cqw and cqh units that work inside the query. The cqw unit is 1% of the container's inline-size, and cqh is 1% of the block-size. Use these units directly in padding, font-size, or gap declarations. They update without any observer. This is the cleanest way to make typography scale with a container: set font-size: clamp(1rem, 2.5cqw, 1.5rem) on a child, and it responds to the container's width with zero JavaScript. A ResizeObserver cannot give you units; it can only give you a number that you must manually convert.
The other advantage is that container queries can be nested. A container can be a child of another container, and you can query the inner one against the outer one's size. ResizeObserver gives you no such hierarchy. You must observe each element separately and coordinate the callbacks yourself. With @container, the browser handles the nesting and the dependency ordering. This is why the migration from ResizeObserver to container queries is not just a performance win. It is a simplification of your codebase.
The Cost of Container Queries You Should Know
The main cost of native container queries is that they cannot be used on the element you want to measure if that element must also be a container. The container-type: inline-size declaration establishes a containing block for its descendants, and you cannot query that same element's size from within itself. This is the gap that keeps ResizeObserver alive. The secondary cost is interop: container units cqw and cqh had bugs in older Safari versions that shipped container queries. Those are largely resolved in current releases, but test in the browsers your audience actually uses. Check the Interop project's results for the current year rather than trusting a blog post from 2023.
How to Choose Between ResizeObserver and Container Queries for a New Component
When you start a new component, follow this order. First, write it with @container and container-type: inline-size on the parent. If that works, you are done. Second, if you need to set a custom property that a sibling or an ancestor reads, switch to ResizeObserver with contentBoxSize and set the custom property in a requestAnimationFrame. Third, if you need the outer dimension including border and padding, use borderBoxSize instead. Fourth, if you are in a fragmented context, such as a multi-column layout, check the length of the size array. If it is greater than one, iterate over all fragments. This is rare, but it is the case where the contentRect fallback is actually more convenient, because it gives you a single rectangle.
Frequently Asked Questions
When should I use ResizeObserver instead of container queries?
Use ResizeObserver when the element you need to measure already has a container-type: inline-size declaration for its children. A container cannot query its own size, so you need the JavaScript bridge to expose that dimension via a custom property.
Does ResizeObserver cause layout thrashing?
No, if you avoid writing styles that change the observed element's size. The observer fires after layout, and writing to a custom property that affects the element's own size triggers a loop. Wrap the write in requestAnimationFrame.
Is container query support universal now?
Size container queries are supported in all major engines since 2023. Container units cqw and cqh had interop bugs in older Safari versions, but those are fixed in current releases. Test in your target browsers.
Can I use ResizeObserver to animate a container query?
No. ResizeObserver gives you a discrete measurement, not a continuous animation. Use CSS transitions or WAAPI for animations. ResizeObserver only sets the breakpoint, not the motion.