I used to think someElement.querySelector('.item') would be faster than document.querySelector('.item') since it only searches a smaller part of the DOM. But while trying to optimize my querySelector calls in an Astro component, I realized something didn’t add up.
The Question That Changed My Mind
If element.querySelector() only searched within that element’s subtree (the “obvious logic”), how would these selectors behave?
// With "normal logic" (hypothetically):
container.querySelector('body .item'); // ❌ Would break - 'body' isn't under container
container.querySelector(':not(.hidden) .item'); // ❌ How would this even work?
container.querySelector('.ancestor .item'); // ❌ What if ancestor isn't under container?
Yet, these selectors work just fine. So the browser isn’t doing what I expected.
Here’s What Actually Happens
CSS selectors have global semantics. When you write .item, it means “any element with class item” — not “relative to where I’m calling from.”
So when you call element.querySelector(), the browser actually:
- Evaluates the selector globally - as if searching the entire document
- Filters the results - keeps only matches that are descendants of your element
This is actually mentioned in the MDN documentation
The Implication
This means element.querySelector() is actually doing more work than document.querySelector() — not less — because of the extra filtering step. My intuition about performance was completely backwards.
Lesson learned: Don’t assume how APIs work under the hood. Always question the edge cases.