Machine Coding Round Catalog
Vanilla JS — Logic First, Pattern Catalog
Format per pattern: Problem → State Model → HTML skeleton → JS implementation → Mistakes that cost rounds CSS is minimal and functional only. The focus is always the logic.
Layer 1: Core Mechanics
(What Every Round Assumes You Know Cold)
1. Debounce
What it is: delay executing a function until the user has stopped triggering it for N ms. If they trigger it again before N ms, reset the timer.
Mental model: every call cancels the previous scheduled call and schedules a new one.
function debounce(fn, delay) {
let timer = null
return function (...args) {
clearTimeout(timer) // cancel the previous scheduled call
timer = setTimeout(() => fn(...args), delay) // schedule a new one
}
}
// Usage
const onSearch = debounce((query) => {
console.log('searching:', query)
}, 300)
input.addEventListener('input', (e) => onSearch(e.target.value))
Mistakes:
- Forgetting
clearTimeout— without it, every call fires, which is just delayed execution, not debounce - Not spreading
...args— you lose the arguments - Calling
debounce(fn, 300)inside the event listener — creates a new debounce instance on every event, so it never actually debounces
2. Throttle
What it is: allow a function to fire at most once per N ms. Unlike debounce, it fires immediately and blocks subsequent calls for N ms.
Mental model: first call fires. A flag blocks all calls for N ms. After N ms, flag resets. Next call fires again.
function throttle(fn, limit) {
let blocked = false
return function (...args) {
if (blocked) return // still in cooldown, ignore
fn(...args) // fire immediately
blocked = true
setTimeout(() => {
blocked = false // cooldown over, ready to fire again
}, limit)
}
}
// Usage: fire at most once per 200ms during scroll
window.addEventListener('scroll', throttle(() => {
console.log('scroll position:', window.scrollY)
}, 200))
Debounce vs Throttle — when to use which:
- Search input → debounce (fire after user stops typing)
- Scroll handler → throttle (fire periodically while scrolling)
- Window resize → debounce (fire after resizing stops)
- Button to prevent double-click → throttle
3. Event Delegation
What it is: instead of attaching one listener per element, attach one listener to the parent. Use event.target to determine which child was clicked.
Why: if you have 500 list items and attach addEventListener to each, you have 500 event listeners in memory. One listener on the parent handles all 500. Also works for dynamically added elements (new items added after the listener is attached still work).
<ul id="list">
<li data-id="1">Item 1</li>
<li data-id="2">Item 2</li>
<li data-id="3">Item 3</li>
</ul>
const list = document.getElementById('list')
list.addEventListener('click', (e) => {
// e.target is the actual element clicked
// e.currentTarget is the element the listener is attached to (the ul)
const item = e.target.closest('li') // handles clicks on children inside <li>
if (!item) return // clicked on the ul itself, not an li
const id = item.dataset.id
console.log('clicked item:', id)
})
e.target.closest('li') — critical pattern. If the <li> contains child elements (like a <span> or <button>), e.target will be the child, not the <li>. .closest('li') walks up the DOM tree until it finds the matching ancestor. Use this always.
Mistakes:
- Using
e.target.tagName === 'LI'— breaks if the user clicks on a child element inside the<li> - Not null-checking after
.closest()— if click is on the container itself,.closest()returnsnull
4. The Closure-in-Loop Trap
Classic interview trap. Understand it once, never get caught.
// WRONG — all buttons alert '3'
for (var i = 0; i < 3; i++) {
document.querySelectorAll('button')[i].addEventListener('click', function() {
alert(i) // 'i' is var — shared across all iterations, ends up as 3
})
}
// FIX 1: use let (block scope — each iteration gets its own 'i')
for (let i = 0; i < 3; i++) {
document.querySelectorAll('button')[i].addEventListener('click', function() {
alert(i) // correct: 0, 1, 2
})
}
// FIX 2: use a closure to capture the value
for (var i = 0; i < 3; i++) {
(function(index) {
document.querySelectorAll('button')[index].addEventListener('click', function() {
alert(index)
})
})(i)
}
// FIX 3: store on the element itself (also common)
buttons.forEach((btn, i) => {
btn.dataset.index = i
btn.addEventListener('click', (e) => alert(e.target.dataset.index))
})
Root cause: var is function-scoped, not block-scoped. The event handler closes over the variable i, not the value of i at the time the loop ran. By the time any click fires, the loop has finished and i === 3. let is block-scoped — each iteration creates a new binding.
5. DOM API You Must Know Cold
// Selection
document.getElementById('id')
document.querySelector('.class') // first match
document.querySelectorAll('.class') // NodeList (not Array — use Array.from or [...])
// Creation and insertion
const el = document.createElement('div')
el.textContent = 'Hello' // safe (no XSS)
el.innerHTML = '<span>Hello</span>' // use with caution
parent.appendChild(el) // add at end
parent.insertBefore(el, referenceNode) // add before a node
parent.removeChild(el) // remove
el.remove() // shorthand remove
// Classes
el.classList.add('active')
el.classList.remove('active')
el.classList.toggle('active')
el.classList.contains('active') // returns boolean
// Attributes
el.setAttribute('data-id', '123')
el.getAttribute('data-id')
el.dataset.id // shorthand for data-* attributes
// Traversal
el.parentElement
el.children // HTMLCollection of direct children
el.firstElementChild
el.lastElementChild
el.nextElementSibling
el.previousElementSibling
el.closest('.selector') // walks up tree, finds ancestor
// Dimensions (these force a layout calculation — batch reads, then writes)
el.offsetWidth / el.offsetHeight // including padding and border
el.clientWidth / el.clientHeight // including padding, excluding border
el.getBoundingClientRect() // { top, left, bottom, right, width, height }
window.scrollY // current scroll position
// Events
el.addEventListener('click', handler)
el.removeEventListener('click', handler) // handler must be same reference
el.dispatchEvent(new CustomEvent('my-event', { detail: { data: 'value' } }))
Layer 2: UI Component Patterns
6. Accordion
Problem: build an accordion where clicking a header expands/collapses its content. Only one section open at a time.
State model:
activeIndex: which panel is currently open (null = none)- Click: if clicked panel is active → close it. If different panel → open it, close previous.
<div class="accordion" id="accordion">
<div class="panel" data-index="0">
<button class="header">Section 1</button>
<div class="content">Content for section 1</div>
</div>
<div class="panel" data-index="1">
<button class="header">Section 2</button>
<div class="content">Content for section 2</div>
</div>
<div class="panel" data-index="2">
<button class="header">Section 3</button>
<div class="content">Content for section 3</div>
</div>
</div>
const accordion = document.getElementById('accordion')
let activeIndex = null
accordion.addEventListener('click', (e) => {
const header = e.target.closest('.header')
if (!header) return
const panel = header.closest('.panel')
const clickedIndex = Number(panel.dataset.index)
// Close currently open panel
if (activeIndex !== null) {
const openPanel = accordion.querySelector(`[data-index="${activeIndex}"]`)
openPanel.querySelector('.content').hidden = true
openPanel.querySelector('.header').setAttribute('aria-expanded', 'false')
}
// If same panel clicked, just close (toggle off)
if (activeIndex === clickedIndex) {
activeIndex = null
return
}
// Open the clicked panel
panel.querySelector('.content').hidden = false
header.setAttribute('aria-expanded', 'true')
activeIndex = clickedIndex
})
// Initialise — all closed
accordion.querySelectorAll('.content').forEach(c => c.hidden = true)
accordion.querySelectorAll('.header').forEach(h => h.setAttribute('aria-expanded', 'false'))
Mistakes:
- Not handling the "click same panel again" case — you end up with no open panel and activeIndex still pointing to the old one
- Using CSS
display: nonedirectly in JS instead ofhiddenattribute —hiddenis semantically correct and simpler - Querying all panels on every click (inside the loop) — query once at init, or use delegation like above
7. Tabs
Problem: build a tab component. Click a tab, show its panel. Active tab is highlighted.
State model:
activeTab: which tab id is active- Click: deactivate all, activate clicked one
<div class="tabs">
<div class="tab-list" role="tablist">
<button class="tab" data-tab="tab1" aria-selected="true">Tab 1</button>
<button class="tab" data-tab="tab2" aria-selected="false">Tab 2</button>
<button class="tab" data-tab="tab3" aria-selected="false">Tab 3</button>
</div>
<div class="tab-panel active" id="tab1">Panel 1 content</div>
<div class="tab-panel" id="tab2">Panel 2 content</div>
<div class="tab-panel" id="tab3">Panel 3 content</div>
</div>
const tabList = document.querySelector('.tab-list')
const tabs = document.querySelectorAll('.tab')
const panels = document.querySelectorAll('.tab-panel')
// Initialise
panels.forEach(p => p.hidden = true)
document.getElementById('tab1').hidden = false
tabList.addEventListener('click', (e) => {
const tab = e.target.closest('.tab')
if (!tab) return
const targetId = tab.dataset.tab
// Deactivate all
tabs.forEach(t => {
t.classList.remove('active')
t.setAttribute('aria-selected', 'false')
})
panels.forEach(p => p.hidden = true)
// Activate clicked
tab.classList.add('active')
tab.setAttribute('aria-selected', 'true')
document.getElementById(targetId).hidden = false
})
If they ask for keyboard support (arrow keys):
tabList.addEventListener('keydown', (e) => {
const tabArray = [...tabs]
const currentIndex = tabArray.indexOf(document.activeElement)
if (e.key === 'ArrowRight') {
const next = tabArray[(currentIndex + 1) % tabArray.length]
next.focus()
next.click()
}
if (e.key === 'ArrowLeft') {
const prev = tabArray[(currentIndex - 1 + tabArray.length) % tabArray.length]
prev.focus()
prev.click()
}
})
8. Modal
Problem: open a modal on button click. Close on X click, backdrop click, or Escape key.
State model:
isOpen: boolean- Three ways to close — handle all three
<button id="open-btn">Open Modal</button>
<div class="modal" id="modal" hidden>
<div class="modal-backdrop"></div>
<div class="modal-content">
<button class="modal-close" id="close-btn">✕</button>
<h2>Modal Title</h2>
<p>Modal content here</p>
</div>
</div>
const modal = document.getElementById('modal')
const openBtn = document.getElementById('open-btn')
const closeBtn = document.getElementById('close-btn')
function openModal() {
modal.hidden = false
document.body.style.overflow = 'hidden' // prevent background scroll
closeBtn.focus() // accessibility: focus the close button
}
function closeModal() {
modal.hidden = true
document.body.style.overflow = ''
openBtn.focus() // return focus to trigger element
}
openBtn.addEventListener('click', openModal)
closeBtn.addEventListener('click', closeModal)
// Close on backdrop click (not on modal content click)
modal.addEventListener('click', (e) => {
if (e.target.classList.contains('modal-backdrop')) {
closeModal()
}
})
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !modal.hidden) {
closeModal()
}
})
Mistakes:
- Not preventing background scroll — page scrolls behind the modal
- Closing modal when clicking the content inside it — the click event bubbles up to the backdrop check. The check
e.target.classList.contains('modal-backdrop')prevents this — only the backdrop element itself triggers close - Not returning focus to the trigger on close — accessibility requirement, also asked by some interviewers
9. Dropdown Menu
Problem: click a button to toggle a dropdown. Close when clicking outside.
State model:
isOpen: boolean- Two close triggers: close button click, click anywhere outside
<div class="dropdown" id="dropdown">
<button class="dropdown-trigger" id="trigger">Options ▾</button>
<ul class="dropdown-menu" id="menu" hidden>
<li><button data-value="edit">Edit</button></li>
<li><button data-value="delete">Delete</button></li>
<li><button data-value="share">Share</button></li>
</ul>
</div>
const dropdown = document.getElementById('dropdown')
const trigger = document.getElementById('trigger')
const menu = document.getElementById('menu')
trigger.addEventListener('click', (e) => {
e.stopPropagation() // prevent the document click handler from firing immediately
const isOpen = !menu.hidden
menu.hidden = isOpen
trigger.setAttribute('aria-expanded', String(!isOpen))
})
// Click outside closes the dropdown
document.addEventListener('click', (e) => {
if (!dropdown.contains(e.target)) {
menu.hidden = true
trigger.setAttribute('aria-expanded', 'false')
}
})
// Handle selection
menu.addEventListener('click', (e) => {
const btn = e.target.closest('button')
if (!btn) return
console.log('selected:', btn.dataset.value)
menu.hidden = true // close after selection
})
The stopPropagation reason: without it, clicking the trigger fires the trigger's click handler AND immediately bubbles to the document click handler, which closes the menu. The menu never opens. stopPropagation on the trigger prevents the document handler from firing on that specific click.
Mistakes:
- Using
windowinstead ofdocumentfor outside click — small difference butdocumentis more correct - Not using
dropdown.contains(e.target)— checking only the trigger or menu separately misses cases
10. Typeahead / Autocomplete
Problem: as user types in an input, fetch suggestions and show a dropdown. Debounce the fetch. Keyboard navigate the suggestions.
State model:
query: current input valuesuggestions: array of results from APIactiveIndex: which suggestion is keyboard-highlighted (-1 = none)isOpen: whether suggestions list is visible
<div class="autocomplete" id="autocomplete">
<input type="text" id="search" placeholder="Search..." autocomplete="off">
<ul id="suggestions" hidden></ul>
</div>
const input = document.getElementById('search')
const suggestionsList = document.getElementById('suggestions')
let suggestions = []
let activeIndex = -1
// Debounced fetch
const fetchSuggestions = debounce(async (query) => {
if (!query.trim()) {
hideSuggestions()
return
}
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`)
suggestions = await res.json()
renderSuggestions()
}, 300)
function renderSuggestions() {
if (suggestions.length === 0) {
hideSuggestions()
return
}
suggestionsList.innerHTML = suggestions.map((item, i) => `
<li data-index="${i}" class="${i === activeIndex ? 'active' : ''}">
${escapeHtml(item.label)}
</li>
`).join('')
suggestionsList.hidden = false
}
function hideSuggestions() {
suggestionsList.hidden = true
activeIndex = -1
}
function escapeHtml(str) {
// Prevent XSS — never put raw user/API data into innerHTML without escaping
const div = document.createElement('div')
div.textContent = str
return div.innerHTML
}
// Input handler
input.addEventListener('input', (e) => {
activeIndex = -1
fetchSuggestions(e.target.value)
})
// Keyboard navigation
input.addEventListener('keydown', (e) => {
if (suggestionsList.hidden) return
if (e.key === 'ArrowDown') {
e.preventDefault() // prevent cursor moving in input
activeIndex = Math.min(activeIndex + 1, suggestions.length - 1)
renderSuggestions()
}
if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex = Math.max(activeIndex - 1, -1)
renderSuggestions()
}
if (e.key === 'Enter' && activeIndex >= 0) {
selectSuggestion(suggestions[activeIndex])
}
if (e.key === 'Escape') {
hideSuggestions()
}
})
// Click selection
suggestionsList.addEventListener('click', (e) => {
const li = e.target.closest('li')
if (!li) return
selectSuggestion(suggestions[Number(li.dataset.index)])
})
function selectSuggestion(item) {
input.value = item.label
hideSuggestions()
console.log('selected:', item)
}
// Close on outside click
document.addEventListener('click', (e) => {
if (!e.target.closest('#autocomplete')) {
hideSuggestions()
}
})
Mistakes:
- Putting raw API data directly into
innerHTMLwithout escaping — XSS vulnerability. Always escape or usetextContent. - Not debouncing — fires a fetch on every keystroke
- Not handling the case where a newer request returns before an older one (race condition). Production fix: use an AbortController to cancel the previous request before making a new one.
Race condition fix:
let currentController = null
const fetchSuggestions = debounce(async (query) => {
if (currentController) currentController.abort() // cancel previous
currentController = new AbortController()
try {
const res = await fetch(`/api/search?q=${query}`, {
signal: currentController.signal
})
suggestions = await res.json()
renderSuggestions()
} catch (e) {
if (e.name === 'AbortError') return // expected, ignore
}
}, 300)
11. Infinite Scroll
Problem: load more items as the user scrolls to the bottom of the list.
State model:
page: current page numberisLoading: prevent multiple simultaneous fetcheshasMore: stop fetching when no more data
Two approaches: scroll event (simpler) vs IntersectionObserver (better).
Approach 1: Scroll event
let page = 1
let isLoading = false
let hasMore = true
const list = document.getElementById('list')
async function loadMore() {
if (isLoading || !hasMore) return
isLoading = true
showLoader()
const data = await fetch(`/api/items?page=${page}`).then(r => r.json())
if (data.items.length === 0) {
hasMore = false
hideLoader()
return
}
data.items.forEach(item => {
const el = document.createElement('div')
el.textContent = item.name
list.appendChild(el)
})
page++
isLoading = false
hideLoader()
}
// Throttle the scroll handler
window.addEventListener('scroll', throttle(() => {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement
const nearBottom = scrollTop + clientHeight >= scrollHeight - 200 // 200px before bottom
if (nearBottom) loadMore()
}, 200))
loadMore() // initial load
Approach 2: IntersectionObserver (cleaner)
const sentinel = document.getElementById('sentinel') // empty div at bottom of list
const observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore()
}
}, {
rootMargin: '200px' // trigger 200px before it actually enters viewport
})
observer.observe(sentinel)
// When no more data
if (!hasMore) observer.disconnect()
IntersectionObserver is the right answer for any interviewer who asks about performance. It fires only when the sentinel element enters the viewport — no scroll event polling, no layout thrashing from reading scrollTop/scrollHeight on every scroll.
12. Star Rating
Problem: render N stars. User can hover to preview a rating and click to set it. Show the set rating when not hovering.
State model:
currentRating: the committed rating (0-5)hoverRating: the preview rating while mouse is over stars (0 = not hovering)- Display rule: if hoverRating > 0, show hoverRating. Otherwise show currentRating.
<div class="stars" id="stars" data-rating="0">
<span data-value="1">★</span>
<span data-value="2">★</span>
<span data-value="3">★</span>
<span data-value="4">★</span>
<span data-value="5">★</span>
</div>
const stars = document.getElementById('stars')
const starEls = stars.querySelectorAll('[data-value]')
let currentRating = 0
function render(displayRating) {
starEls.forEach(star => {
const val = Number(star.dataset.value)
star.classList.toggle('filled', val <= displayRating)
})
}
stars.addEventListener('mouseover', (e) => {
const star = e.target.closest('[data-value]')
if (!star) return
render(Number(star.dataset.value)) // preview
})
stars.addEventListener('mouseleave', () => {
render(currentRating) // reset to committed rating
})
stars.addEventListener('click', (e) => {
const star = e.target.closest('[data-value]')
if (!star) return
const val = Number(star.dataset.value)
// Clicking the same rating again resets to 0 (toggle off)
currentRating = currentRating === val ? 0 : val
stars.dataset.rating = currentRating
render(currentRating)
})
render(0) // init
Mental model: there are always two values — what the user has set (currentRating) and what they're hovering on (hoverRating). The display always shows hover if it exists, otherwise shows the committed rating. On mouseleave, reset to committed.
13. Toast Notifications
Problem: show a toast notification. Auto-dismiss after N seconds. Support multiple toasts stacked. Support manual dismiss.
State model:
- A container holds all active toasts
- Each toast has: id, message, type, timer
- Dismiss = remove DOM element + clear timer
<div id="toast-container"></div>
<button onclick="showToast('Saved!', 'success')">Save</button>
const container = document.getElementById('toast-container')
function showToast(message, type = 'info', duration = 3000) {
const id = Date.now() // simple unique id
const toast = document.createElement('div')
toast.className = `toast toast-${type}`
toast.dataset.id = id
toast.innerHTML = `
<span>${message}</span>
<button class="toast-close" onclick="dismissToast(${id})">✕</button>
`
container.appendChild(toast)
// Auto-dismiss
const timer = setTimeout(() => dismissToast(id), duration)
toast.dataset.timer = timer // store timer id on element for cleanup
// Animate in
requestAnimationFrame(() => toast.classList.add('visible'))
return id
}
function dismissToast(id) {
const toast = container.querySelector(`[data-id="${id}"]`)
if (!toast) return
clearTimeout(Number(toast.dataset.timer))
toast.classList.remove('visible')
// Remove from DOM after transition
toast.addEventListener('transitionend', () => toast.remove(), { once: true })
}
The { once: true } option on addEventListener — the listener automatically removes itself after firing once. Without this, every transitionend would fire the listener again if more transitions happen on the element.
Mistakes:
- Not clearing the timer when manually dismissed — if the user dismisses a toast and then the timer fires, it tries to find and remove an already-removed element (harmless but sloppy)
- Mutating innerHTML of the container on each toast — always append/remove individual elements, never replace the entire container
14. Form Validation
Problem: validate a form on submit. Show field-level errors. Prevent submission if invalid.
State model:
- Validate each field against rules
errors: map of fieldName → errorMessage- Show errors, focus first invalid field, block submit
<form id="form">
<div class="field">
<input type="text" name="name" placeholder="Full Name" />
<span class="error" data-field="name"></span>
</div>
<div class="field">
<input type="email" name="email" placeholder="Email" />
<span class="error" data-field="email"></span>
</div>
<div class="field">
<input type="password" name="password" placeholder="Password" />
<span class="error" data-field="password"></span>
</div>
<button type="submit">Submit</button>
</form>
const form = document.getElementById('form')
// Validation rules — easily extensible
const rules = {
name: [
{ test: v => v.trim().length > 0, message: 'Name is required' },
{ test: v => v.trim().length >= 2, message: 'Name must be at least 2 characters' },
],
email: [
{ test: v => v.trim().length > 0, message: 'Email is required' },
{ test: v => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v), message: 'Invalid email format' },
],
password: [
{ test: v => v.length > 0, message: 'Password is required' },
{ test: v => v.length >= 8, message: 'Password must be at least 8 characters' },
],
}
function validateField(name, value) {
const fieldRules = rules[name]
if (!fieldRules) return null
for (const rule of fieldRules) {
if (!rule.test(value)) return rule.message
}
return null // no error
}
function showError(fieldName, message) {
const errorEl = form.querySelector(`[data-field="${fieldName}"]`)
const input = form.querySelector(`[name="${fieldName}"]`)
if (errorEl) errorEl.textContent = message || ''
if (input) input.classList.toggle('invalid', !!message)
}
form.addEventListener('submit', (e) => {
e.preventDefault()
const data = Object.fromEntries(new FormData(form))
let firstError = null
// Validate all fields
for (const [name, value] of Object.entries(data)) {
const error = validateField(name, value)
showError(name, error)
if (error && !firstError) {
firstError = name
}
}
if (firstError) {
form.querySelector(`[name="${firstError}"]`).focus()
return
}
// All valid — submit
console.log('submitting:', data)
})
// Inline validation on blur (validate as user leaves a field)
form.addEventListener('blur', (e) => {
const input = e.target
if (!input.name || !rules[input.name]) return
const error = validateField(input.name, input.value)
showError(input.name, error)
}, true) // capture phase to catch blur (it doesn't bubble)
new FormData(form) — native browser API that collects all named form inputs into key-value pairs. Object.fromEntries() converts it to a plain object. Cleaner than querying each input manually.
blur with capture phase — blur does not bubble. To catch it via event delegation on the form, you must use the capture phase (true as third argument to addEventListener).
15. Drag and Drop (From Scratch)
Problem: make list items reorderable by dragging.
State model:
draggedEl: the element being dragged- Three events:
dragstart,dragover,drop
<ul id="drag-list">
<li draggable="true" data-id="1">Item 1</li>
<li draggable="true" data-id="2">Item 2</li>
<li draggable="true" data-id="3">Item 3</li>
</ul>
const list = document.getElementById('drag-list')
let draggedEl = null
list.addEventListener('dragstart', (e) => {
draggedEl = e.target
e.target.classList.add('dragging')
e.dataTransfer.effectAllowed = 'move'
})
list.addEventListener('dragend', (e) => {
e.target.classList.remove('dragging')
draggedEl = null
// Remove any visual indicators
list.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))
})
list.addEventListener('dragover', (e) => {
e.preventDefault() // REQUIRED — without this, drop event won't fire
const target = e.target.closest('li')
if (!target || target === draggedEl) return
// Determine whether to insert before or after
const rect = target.getBoundingClientRect()
const midY = rect.top + rect.height / 2
if (e.clientY < midY) {
list.insertBefore(draggedEl, target)
} else {
list.insertBefore(draggedEl, target.nextSibling)
}
})
list.addEventListener('drop', (e) => {
e.preventDefault()
// The actual reordering happens in dragover, drop just finalises
})
e.preventDefault() in dragover is mandatory. Without it, the browser treats the drop target as invalid and the drop event never fires. This is the #1 drag-and-drop mistake.
Insert above/below logic — getBoundingClientRect() gets the target element's position. If the mouse is above the midpoint of the target, insert before. If below, insert after. This gives natural feel.
16. Pagination
Problem: display paginated data. Render page number buttons. Handle prev/next and direct page clicks.
State model:
currentPage: 1-indexedtotalPages: derived fromtotal / pageSize- Click: update currentPage, fetch new data, re-render
<div id="items-container"></div>
<div id="pagination"></div>
const PAGE_SIZE = 10
let currentPage = 1
let totalItems = 0
async function loadPage(page) {
const res = await fetch(`/api/items?page=${page}&size=${PAGE_SIZE}`)
const { items, total } = await res.json()
totalItems = total
renderItems(items)
renderPagination(Math.ceil(total / PAGE_SIZE))
currentPage = page
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function renderItems(items) {
const container = document.getElementById('items-container')
container.innerHTML = items.map(item => `
<div class="item">${escapeHtml(item.name)}</div>
`).join('')
}
function renderPagination(totalPages) {
const el = document.getElementById('pagination')
// Build page numbers — show max 5 around current page
const pages = getPageRange(currentPage, totalPages)
el.innerHTML = `
<button data-page="${currentPage - 1}" ${currentPage === 1 ? 'disabled' : ''}>‹ Prev</button>
${pages.map(p =>
p === '...'
? '<span>...</span>'
: `<button data-page="${p}" class="${p === currentPage ? 'active' : ''}">${p}</button>`
).join('')}
<button data-page="${currentPage + 1}" ${currentPage === totalPages ? 'disabled' : ''}>Next ›</button>
`
}
function getPageRange(current, total) {
// Always show: first, last, current, and 1 on each side of current
const pages = new Set([1, total, current, current - 1, current + 1])
const sorted = [...pages].filter(p => p >= 1 && p <= total).sort((a, b) => a - b)
// Insert '...' where gaps exist
const result = []
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i] - sorted[i - 1] > 1) result.push('...')
result.push(sorted[i])
}
return result
}
document.getElementById('pagination').addEventListener('click', (e) => {
const btn = e.target.closest('button')
if (!btn || btn.disabled) return
loadPage(Number(btn.dataset.page))
})
loadPage(1)
17. Search with Live Filter (No API)
Problem: filter a list as the user types. No API — filter from an in-memory array.
State model:
allItems: the full list (never mutated)query: current search string- On each input: filter
allItemsand re-render
const allItems = [
{ id: 1, name: 'Apple' },
{ id: 2, name: 'Banana' },
{ id: 3, name: 'Cherry' },
// ...
]
const input = document.getElementById('search')
const list = document.getElementById('list')
function render(items) {
if (items.length === 0) {
list.innerHTML = '<li>No results</li>'
return
}
list.innerHTML = items.map(item => `<li>${escapeHtml(item.name)}</li>`).join('')
}
input.addEventListener('input', debounce((e) => {
const query = e.target.value.toLowerCase().trim()
const filtered = query
? allItems.filter(item => item.name.toLowerCase().includes(query))
: allItems
render(filtered)
}, 200))
render(allItems) // initial render
Never mutate the original array. allItems is the source of truth. Always filter from it, never from a filtered copy. This is the mistake that causes "I can only filter once and can't go back."
18. Multi-Step Form / Wizard
Problem: a form split into multiple steps. Next/Back navigation. Validate each step before allowing next.
State model:
currentStep: 0-indexedformData: accumulated data across all steps- Validate current step on Next click
<div id="wizard">
<div class="step" data-step="0">
<h2>Step 1: Personal Info</h2>
<input name="name" placeholder="Name" />
<input name="email" placeholder="Email" />
</div>
<div class="step" data-step="1" hidden>
<h2>Step 2: Account</h2>
<input name="username" placeholder="Username" />
<input name="password" type="password" placeholder="Password" />
</div>
<div class="step" data-step="2" hidden>
<h2>Step 3: Review</h2>
<div id="summary"></div>
</div>
<div class="controls">
<button id="back-btn" hidden>Back</button>
<button id="next-btn">Next</button>
</div>
<div id="progress">Step 1 of 3</div>
</div>
const steps = document.querySelectorAll('.step')
const nextBtn = document.getElementById('next-btn')
const backBtn = document.getElementById('back-btn')
const progress = document.getElementById('progress')
let currentStep = 0
let formData = {}
const validations = {
0: () => {
const name = document.querySelector('[name="name"]').value.trim()
const email = document.querySelector('[name="email"]').value.trim()
if (!name) return 'Name is required'
if (!email || !email.includes('@')) return 'Valid email is required'
return null
},
1: () => {
const username = document.querySelector('[name="username"]').value.trim()
const password = document.querySelector('[name="password"]').value
if (!username || username.length < 3) return 'Username must be at least 3 characters'
if (!password || password.length < 8) return 'Password must be at least 8 characters'
return null
},
}
function goToStep(step) {
steps[currentStep].hidden = true
currentStep = step
steps[currentStep].hidden = false
backBtn.hidden = currentStep === 0
nextBtn.textContent = currentStep === steps.length - 1 ? 'Submit' : 'Next'
progress.textContent = `Step ${currentStep + 1} of ${steps.length}`
if (currentStep === steps.length - 1) {
renderSummary()
}
}
function collectCurrentStepData() {
const currentStepEl = steps[currentStep]
const inputs = currentStepEl.querySelectorAll('input')
inputs.forEach(input => {
formData[input.name] = input.value
})
}
function renderSummary() {
document.getElementById('summary').innerHTML = Object.entries(formData)
.map(([key, val]) => `<p><strong>${key}:</strong> ${escapeHtml(val)}</p>`)
.join('')
}
nextBtn.addEventListener('click', () => {
const validate = validations[currentStep]
if (validate) {
const error = validate()
if (error) {
alert(error) // in a real app, show inline error
return
}
}
collectCurrentStepData()
if (currentStep === steps.length - 1) {
console.log('submitting:', formData)
return
}
goToStep(currentStep + 1)
})
backBtn.addEventListener('click', () => {
goToStep(currentStep - 1)
})
19. Character Counter
Problem: show remaining characters as user types. Warn when near/at limit. Disable input at limit.
<div class="input-wrapper">
<textarea id="bio" maxlength="280" placeholder="Write your bio..."></textarea>
<span id="counter">280 remaining</span>
</div>
const textarea = document.getElementById('bio')
const counter = document.getElementById('counter')
const MAX = 280
textarea.addEventListener('input', () => {
const remaining = MAX - textarea.value.length
counter.textContent = `${remaining} remaining`
counter.className = remaining <= 20
? (remaining <= 0 ? 'counter-limit' : 'counter-warn')
: ''
})
Simple but interviewers use it to check: do you know maxlength? Do you update on input vs keydown? (keydown fires before the character is added, input fires after — always use input for value-dependent UI).
20. OTP Input
Problem: 6 boxes, one digit each. Auto-advance to next box on input. Backspace goes back. Paste fills all boxes.
<div class="otp" id="otp">
<input type="text" maxlength="1" inputmode="numeric" />
<input type="text" maxlength="1" inputmode="numeric" />
<input type="text" maxlength="1" inputmode="numeric" />
<input type="text" maxlength="1" inputmode="numeric" />
<input type="text" maxlength="1" inputmode="numeric" />
<input type="text" maxlength="1" inputmode="numeric" />
</div>
const otp = document.getElementById('otp')
const inputs = [...otp.querySelectorAll('input')]
inputs.forEach((input, index) => {
// Only allow digits
input.addEventListener('keydown', (e) => {
if (!/^\d$/.test(e.key) && !['Backspace', 'Delete', 'Tab', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault()
}
})
input.addEventListener('input', () => {
if (input.value && index < inputs.length - 1) {
inputs[index + 1].focus() // auto-advance
}
checkComplete()
})
input.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !input.value && index > 0) {
inputs[index - 1].focus() // go back on backspace when empty
}
})
// Handle paste — fill all boxes from pasted text
input.addEventListener('paste', (e) => {
e.preventDefault()
const paste = e.clipboardData.getData('text').replace(/\D/g, '') // digits only
paste.split('').slice(0, inputs.length).forEach((char, i) => {
inputs[i].value = char
})
// Focus last filled input
const lastFilled = Math.min(paste.length, inputs.length) - 1
inputs[lastFilled].focus()
checkComplete()
})
})
function checkComplete() {
const value = inputs.map(i => i.value).join('')
if (value.length === inputs.length) {
console.log('OTP complete:', value)
}
}
inputs[0].focus() // auto-focus first input
Layer 3: The Harder Patterns
21. Custom Event Emitter (pub/sub)
Problem: implement an event emitter class with on, off, emit, and once.
This is asked in almost every senior round.
class EventEmitter {
constructor() {
this.events = {} // { eventName: [handlers] }
}
on(event, handler) {
if (!this.events[event]) {
this.events[event] = []
}
this.events[event].push(handler)
return this // allow chaining: emitter.on('a', fn).on('b', fn2)
}
off(event, handler) {
if (!this.events[event]) return this
this.events[event] = this.events[event].filter(h => h !== handler)
return this
}
emit(event, ...args) {
if (!this.events[event]) return this
// Spread to copy — so removing during emit doesn't affect current iteration
[...this.events[event]].forEach(handler => handler(...args))
return this
}
once(event, handler) {
// Wrapper that removes itself after first call
const wrapper = (...args) => {
handler(...args)
this.off(event, wrapper)
}
// Store reference to wrapper on the original handler
// so off(event, originalHandler) works
wrapper._original = handler
return this.on(event, wrapper)
}
// off with original handler reference support
off(event, handler) {
if (!this.events[event]) return this
this.events[event] = this.events[event].filter(h =>
h !== handler && h._original !== handler
)
return this
}
}
// Usage
const emitter = new EventEmitter()
emitter.on('data', (payload) => console.log('received:', payload))
emitter.emit('data', { id: 1 }) // received: { id: 1 }
emitter.once('connect', () => console.log('connected')) // fires once, then removes itself
emitter.emit('connect') // 'connected'
emitter.emit('connect') // nothing — already removed
The once subtlety: off(event, originalHandler) must work even though the actual registered handler is a wrapper. The _original property stores the reference so the filter can match by original handler.
22. Promise Implementation (from scratch)
Asked to test if you actually understand async — not just how to use it.
class MyPromise {
constructor(executor) {
this.state = 'pending' // 'pending' | 'fulfilled' | 'rejected'
this.value = undefined
this.handlers = [] // { onFulfilled, onRejected, resolve, reject } for chaining
const resolve = (value) => {
if (this.state !== 'pending') return
this.state = 'fulfilled'
this.value = value
this.handlers.forEach(h => this._handleResolved(h))
}
const reject = (reason) => {
if (this.state !== 'pending') return
this.state = 'rejected'
this.value = reason
this.handlers.forEach(h => this._handleResolved(h))
}
try {
executor(resolve, reject)
} catch (e) {
reject(e)
}
}
_handleResolved({ onFulfilled, onRejected, resolve, reject }) {
// Run asynchronously (microtask) — real Promises resolve async
queueMicrotask(() => {
try {
if (this.state === 'fulfilled') {
if (typeof onFulfilled === 'function') {
resolve(onFulfilled(this.value))
} else {
resolve(this.value) // pass through
}
} else if (this.state === 'rejected') {
if (typeof onRejected === 'function') {
resolve(onRejected(this.value)) // caught = resolved
} else {
reject(this.value) // unhandled = propagate
}
}
} catch (e) {
reject(e)
}
})
}
then(onFulfilled, onRejected) {
return new MyPromise((resolve, reject) => {
const handler = { onFulfilled, onRejected, resolve, reject }
if (this.state === 'pending') {
this.handlers.push(handler)
} else {
this._handleResolved(handler) // already settled — run immediately
}
})
}
catch(onRejected) {
return this.then(null, onRejected)
}
static resolve(value) {
return new MyPromise(resolve => resolve(value))
}
static reject(reason) {
return new MyPromise((_, reject) => reject(reason))
}
static all(promises) {
return new MyPromise((resolve, reject) => {
const results = []
let resolved = 0
if (promises.length === 0) { resolve([]); return }
promises.forEach((p, i) => {
MyPromise.resolve(p).then(value => {
results[i] = value
resolved++
if (resolved === promises.length) resolve(results)
}).catch(reject)
})
})
}
static race(promises) {
return new MyPromise((resolve, reject) => {
promises.forEach(p => {
MyPromise.resolve(p).then(resolve).catch(reject)
})
})
}
}
What interviewers look for:
- State machine: pending → fulfilled/rejected (one-way, no reverting)
.then()returns a new Promise (enabling chaining)- Async resolution via
queueMicrotask(notsetTimeout) - Error propagation through the chain
23. Virtual List (Windowing from Scratch)
Problem: render a list of 100,000 items without crashing the browser.
Core idea: only render items visible in the viewport. Use a tall container (representing the full list height) and absolutely position only the visible rows.
<div id="virtual-list"></div>
const ITEM_HEIGHT = 50 // fixed height per item (required for simple virtualisation)
const CONTAINER_HEIGHT = 600 // the viewport height
const BUFFER = 5 // extra rows to render above/below viewport
const items = Array.from({ length: 100000 }, (_, i) => ({ id: i, name: `Item ${i}` }))
const container = document.getElementById('virtual-list')
container.style.height = `${CONTAINER_HEIGHT}px`
container.style.overflow = 'auto'
container.style.position = 'relative'
// Inner div — full height so the scrollbar is correct
const inner = document.createElement('div')
inner.style.height = `${items.length * ITEM_HEIGHT}px`
inner.style.position = 'relative'
container.appendChild(inner)
function getVisibleRange(scrollTop) {
const startIndex = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER)
const endIndex = Math.min(
items.length - 1,
Math.ceil((scrollTop + CONTAINER_HEIGHT) / ITEM_HEIGHT) + BUFFER
)
return { startIndex, endIndex }
}
function render(scrollTop) {
const { startIndex, endIndex } = getVisibleRange(scrollTop)
// Clear only what's rendered (not the entire inner)
inner.innerHTML = ''
for (let i = startIndex; i <= endIndex; i++) {
const el = document.createElement('div')
el.style.position = 'absolute'
el.style.top = `${i * ITEM_HEIGHT}px`
el.style.height = `${ITEM_HEIGHT}px`
el.style.width = '100%'
el.textContent = items[i].name
inner.appendChild(el)
}
}
container.addEventListener('scroll', throttle(() => {
render(container.scrollTop)
}, 16)) // ~60fps
render(0) // initial render
What you're doing: the container has a fixed visible height with overflow: auto. The inner div is 100,000 × 50px = 5,000,000px tall — this creates the correct scrollbar. You only render ~12-15 items at any time (viewport / item height + buffer). As the user scrolls, you recompute which items are visible and swap them in.
24. Promise.all, Promise.allSettled, Promise.race — Implement Them
// Promise.all — resolves when ALL resolve, rejects if ANY reject
function promiseAll(promises) {
return new Promise((resolve, reject) => {
const results = new Array(promises.length)
let remaining = promises.length
if (remaining === 0) { resolve([]); return }
promises.forEach((p, i) => {
Promise.resolve(p)
.then(val => {
results[i] = val // preserve order
remaining--
if (remaining === 0) resolve(results)
})
.catch(reject) // first rejection rejects the whole thing
})
})
}
// Promise.allSettled — waits for ALL, never rejects, returns status objects
function promiseAllSettled(promises) {
return new Promise((resolve) => {
const results = new Array(promises.length)
let remaining = promises.length
if (remaining === 0) { resolve([]); return }
promises.forEach((p, i) => {
Promise.resolve(p)
.then(value => {
results[i] = { status: 'fulfilled', value }
})
.catch(reason => {
results[i] = { status: 'rejected', reason }
})
.finally(() => {
remaining--
if (remaining === 0) resolve(results)
})
})
})
}
// Promise.race — resolves/rejects with the first settled promise
function promiseRace(promises) {
return new Promise((resolve, reject) => {
promises.forEach(p => {
Promise.resolve(p).then(resolve).catch(reject)
})
})
}
// Promise.any — resolves with first fulfilled, rejects if ALL reject
function promiseAny(promises) {
return new Promise((resolve, reject) => {
let rejectedCount = 0
const errors = new Array(promises.length)
promises.forEach((p, i) => {
Promise.resolve(p)
.then(resolve) // first success wins
.catch(err => {
errors[i] = err
rejectedCount++
if (rejectedCount === promises.length) {
reject(new AggregateError(errors, 'All promises rejected'))
}
})
})
})
}
25. Intersection Observer — Lazy Load and Scroll-Triggered Animations
// Lazy load images
const images = document.querySelectorAll('img[data-src]')
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return
const img = entry.target
img.src = img.dataset.src // swap data-src to src — triggers load
img.removeAttribute('data-src')
observer.unobserve(img) // stop observing once loaded
})
}, {
rootMargin: '200px', // load 200px before they enter viewport
})
images.forEach(img => imageObserver.observe(img))
// Scroll-triggered animation (reveal on scroll)
const animatedEls = document.querySelectorAll('.animate-on-scroll')
const animationObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('visible')
}
})
}, {
threshold: 0.1 // trigger when 10% of element is visible
})
animatedEls.forEach(el => animationObserver.observe(el))
threshold: a number from 0 to 1. 0 = trigger when even 1px is visible. 1 = trigger when fully visible. 0.5 = trigger when 50% is visible.
rootMargin: like margin on the intersection root. Positive values expand the trigger zone (load before entering viewport). Negative values shrink it.
Quick Reference: Patterns by Interview Frequency
Almost always asked (know these cold):
- Debounce / Throttle
- Event Delegation
- Accordion / Tabs / Modal
- Infinite Scroll
- Form Validation
Commonly asked:
- Autocomplete / Typeahead
- Dropdown
- Drag and Drop
- Pagination
- Toast Notifications
- Star Rating
Asked at senior level:
- Custom Event Emitter
- Promise implementation
- Virtual List
- Promise.all / race / allSettled implementation
- IntersectionObserver patterns
The 5 Things That Lose Rounds
1. Not having a state model before writing code. Before typing HTML, say out loud or comment: "My state is X. Events that change it are Y. The render function takes state and produces DOM." Engineers who jump to code first write themselves into corners.
2. Mutating the original data.
Filter from a copy. Render from the original. Never splice, sort, or reassign a variable that holds the source of truth.
3. Not handling edge cases:
- Empty state (no results, no items)
- Loading state (async hasn't resolved)
- Error state (fetch failed) Interviewers specifically wait to see if you handle these.
4. Memory leaks — event listeners not cleaned up.
In a multi-component vanilla app, removing a DOM element does not automatically remove its event listeners. If you add them, remove them. Especially timers (clearTimeout, clearInterval).
5. innerHTML with unsanitised data.
If the data comes from a user or an API, always use textContent or escape before putting in innerHTML. Interviewers who care about security will mark you down hard for this.