Most JavaScript developers have solved the two sum problem at some point, in a coding challenge, a technical interview, or a tutorial. Most have also written a nested loop in production code that worked but felt slow, or a validation function that returned true or false and left the caller guessing why it failed.
The problems in this post aren't here because they come up in interviews. They're here because the patterns they teach, hash maps for fast lookup, binary search for sorted data, multi-condition validation with meaningful errors, flattening nested structures, and debouncing expensive operations, show up in real production code constantly. The interview framing is just a useful starting point.
Problem 1: Two Sum
Interview framing: given an array of integers, return the indices of the two numbers that add up to a target value.
Production equivalent: the hash map lookup pattern. Anywhere in production code where you need to find a relationship between two pieces of data without scanning everything twice, you're solving a variant of two sum.
Two places I've hit this directly:
Finding duplicate submissions in Formgrid. When a form receives a submission, Formgrid needs to check whether the same email address has submitted recently to catch spam. A naive approach loops through all recent submissions, comparing each email one at a time. A hash map approach stores seen emails as keys and checks for existence in constant time instead of scanning the whole list. At low volume, the difference is invisible. At real volume it's the difference between a fast API and a slow one.
Matching users to their subscriptions in a SaaS billing system. Given a list of users and a separate list of active subscriptions, finding which users have active subscriptions is a two-sum-style problem. The naive solution loops through both lists for every user, giving you n times m complexity. The hash map solution indexes subscriptions by user ID first, then checks each user in constant time, giving you n plus m total instead.
Naive solution, order n squared:
const twoSum = (array: number[], goal: number): number[] => {
const indexes: number[] = [];
for (let i = 0; i < array.length; i++) {
for (let j = i + 1; j < array.length; j++) {
if (array[i] + array[j] === goal) {
indexes.push(i);
indexes.push(j);
}
}
}
return indexes;
};
Optimized solution, order n:
const twoSum = (array: number[], goal: number): number[] => {
const seen: Record<number, number> = {};
const result: number[] = [];
for (let i = 0; i < array.length; i++) {
seen[array[i]] = i;
}
for (let i = 0; i < array.length; i++) {
const target = goal - array[i];
if (seen[target] !== undefined && seen[target] !== i) {
result.push(i);
result.push(seen[target]);
break;
}
}
return result;
};
Worth admitting: an older version of this exact function, one I published myself, checked seen[target] !== null instead of seen[target] !== undefined. That's a real bug, not a style choice. A plain JavaScript object returns undefined for a missing key, never null. Checking against null means a missing key still passes the !== null test, since undefined !== null is true. That bug hides quietly through most testing and only surfaces when a valid index of 0 exists in the array, since 0 is falsy but genuinely present. It's exactly the kind of mistake that looks fine locally and produces wrong results in production. The correct check is !== undefined.
The production lesson: when you find yourself writing a loop inside a loop, ask whether a hash map could eliminate the inner loop. Most of the time it can, and the improvement compounds as the data grows.
Problem 2: Search Insert Position
Interview framing: given a sorted array, find the index of a target value, or the index where it should be inserted to keep the array sorted.
Production equivalent: binary search. Sorted data with fast lookup requirements shows up constantly in production systems. Database indexes are essentially binary search trees. Sorted API responses where the client needs to find or insert a record efficiently use this same pattern.
Finding where to insert a new pipeline stage in Formgrid. Formgrid's lead pipeline has stages ordered by position. When a user adds a new stage between two existing ones, the system needs to find the correct insertion point in that sorted list. A linear scan works fine at three stages. Binary search is the right mental model for understanding why database indexes solve the same problem at millions of rows.
Finding the correct tier for a subscription limit check. Formgrid has multiple plan tiers with different submission limits. Given a user's current usage, finding which tier message to show them is a search insert problem: the tiers are sorted by limit value, and the code finds where the current usage falls in that sorted list.
Linear scan, order n:
const searchInsert = (arr: number[], target: number): number => {
for (let i = 0; i < arr.length; i++) {
if (arr[i] >= target) {
return i;
}
}
return arr.length;
};
Binary search, order log n:
const searchInsert = (arr: number[], target: number): number => {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return left;
};
The production lesson: whenever you're searching through sorted data, ask whether binary search applies. A linear scan is fine for small arrays. As the array grows, binary search scales logarithmically, meaning doubling the data only adds one more comparison. This is why database indexes are so powerful. A table with a million rows finds a record in roughly twenty comparisons through a binary search tree index. Without an index, it scans every row.
Problem 3: Triangle Validity
Interview framing: given three sides of a triangle, determine whether they form a valid triangle.
Production equivalent: constraint validation. Every form, every API endpoint, every data processing pipeline involves checking whether inputs satisfy a set of constraints before processing them. The triangle problem is a clean example of multi-condition validation, where every condition has to hold at once.
Form submission validation in Formgrid. When a submission arrives, Formgrid validates several conditions simultaneously before accepting it: the payload isn't empty, the form is active, the user is within their submission limit, and the honeypot field is empty. If any single condition fails, the submission is rejected. Same shape as triangle validity, where all three conditions must hold at once.
Date range validation in a booking form. A hotel or event booking form needs to validate that the check-in date is before the check-out date, both dates are in the future, and the stay duration doesn't exceed the maximum allowed. Three conditions, all of which must be true simultaneously. Same structure as the triangle problem.
The original boolean solution:
const isValidTriangle = (a: number, b: number, c: number): boolean =>
a + b > c && a + c > b && b + c > a;
The production version, with a reason attached instead of a bare boolean:
type ValidationResult =
| { valid: true }
| { valid: false; reason: string };
const validateTriangle = (
a: number,
b: number,
c: number
): ValidationResult => {
if (a + b <= c) {
return { valid: false, reason: "a plus b must be greater than c" };
}
if (a + c <= b) {
return { valid: false, reason: "a plus c must be greater than b" };
}
if (b + c <= a) {
return { valid: false, reason: "b plus c must be greater than a" };
}
return { valid: true };
};
The production lesson: returning a plain boolean from a validation function is rarely enough in a real system. The caller needs to know not just whether the input is valid, but why it's invalid, so they can show a useful error message to the user or log something meaningful for debugging. The discriminated union pattern, either a success state or a failure state with a reason attached, is used everywhere in production TypeScript.
Problem 4: Flatten a Nested Array or Object
Interview framing: given a nested array or a deeply nested object, flatten it into a single-level structure.
Production equivalent: this shows up constantly when working with API responses that return nested data, when normalizing database query results, or when transforming configuration objects before passing them to another system.
Flattening form submission payloads in Formgrid. Form submissions arrive as nested JSON when forms have grouped fields or multi-step layouts. Before writing a submission as a row in Google Sheets or a page in Notion, that nested payload needs to be flattened into a single-level key-value structure, where each key becomes a column header or a Notion property. A nested object like address containing street, city, and country needs to become address_street, address_city, and address_country as flat keys.
Normalizing Google Sheets data in SheetRocket. Google Sheets returns data as a nested array of arrays, where each inner array is a row, and each element is a cell value. SheetRocket flattens this into an array of plain objects, where each key is the column header and each value is the cell value, so the REST API response stays clean and predictable for any frontend consuming it.
A naive solution for one level deep:
const flattenOnce = (arr: any[]): any[] => {
return arr.reduce((flat, item) => flat.concat(item), []);
};
A recursive solution for any depth:
const flattenDeep = (arr: any[]): any[] => {
return arr.reduce(
(flat, item) => flat.concat(Array.isArray(item) ? flattenDeep(item) : item),
[]
);
};
Flattening a nested object into underscore-separated keys:
const flattenObject = (
obj: Record<string, any>,
prefix = ''
): Record<string, any> => {
return Object.keys(obj).reduce((acc, key) => {
const fullKey = prefix ? `${prefix}_${key}` : key;
if (
typeof obj[key] === 'object' &&
obj[key] !== null &&
!Array.isArray(obj[key])
) {
Object.assign(acc, flattenObject(obj[key], fullKey));
} else {
acc[fullKey] = obj[key];
}
return acc;
}, {} as Record<string, any>);
};
The modern built-in alternative for arrays, using Array.flat:
const arr = [1, [2, [3, [4]]]];
arr.flat();
// [1, 2, [3, [4]]]
arr.flat(Infinity);
// [1, 2, 3, 4]
The production lesson: Array.flat covers most array flattening cases in modern JavaScript; no need to write it yourself anymore. Nested object flattening still requires a custom recursive function, though. The key insight either way is that flattening is fundamentally a reduce operation; you're reducing a complex structure into a simpler one by applying the same transformation at every level. Once you see it as reduce, the recursive version stops feeling clever and starts feeling obvious.
Problem 5: Debounce a Function
Interview framing: implement a debounce function that delays executing a callback until a specified wait time has elapsed since the last time it was invoked.
Production equivalent: debounce is one of the most used utility patterns in real frontend code. Any expensive operation triggered by frequent user input needs debouncing to avoid hammering an API or doing unnecessary computation on every event.
Search input in a dashboard. Formgrid's leads tab has a search input that filters submissions by name or email. Without debounce, every keystroke triggers a filter operation across potentially hundreds of submissions, or an API call. With debounce, the filter only runs after the user stops typing for 300 milliseconds. The experience feels instant to the user, but the actual work happens far less often.
Form builder autosave in Formgrid. When a user drags a field in the form builder, the position change triggers a state update. Without debounce, every pixel of movement during a drag would trigger a save operation. Debouncing the save to fire only after the user has stopped moving for 500 milliseconds means dozens of drag events produce exactly one save operation.
API search calls in SheetRocket. When a user filters their Google Sheets data through a SheetRocket widget, the filter input is debounced so the REST API isn't called on every character typed. At 50 characters per second of fast typing, that would otherwise be 50 API calls. With debounce, it's one call after the user pauses.
A basic implementation from scratch:
const debounce = <T extends (...args: any[]) => any>(
fn: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeoutId: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => fn(...args), wait);
};
};
Usage:
const handleSearch = debounce((query: string) => {
fetchResults(query);
}, 300);
input.addEventListener('input', (e) => handleSearch(e.target.value));
A React hook version for components:
import { useCallback, useRef } from 'react';
function useDebounce<T extends (...args: any[]) => any>(fn: T, wait: number) {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
return useCallback(
(...args: Parameters<T>) => {
clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => fn(...args), wait);
},
[fn, wait]
);
}
Worth being precise about the difference between debounce and throttle here, since they get confused constantly. Debounce waits until the user has stopped for the full wait period, then fires once. It's the right choice for search inputs and autosave, where you want to act after the user finishes, not while they're still going. Throttle fires at most once per wait period, no matter how many times it's called in between. It's the right choice for scroll handlers and resize events, where you want steady updates during a continuous action, not just a single one at the end.
The production lesson: debounce and throttle are both about controlling how often an expensive operation runs in response to frequent events. Which one you want depends entirely on whether you need to act after the user stops, or at regular intervals while they're still going. Getting this choice wrong is one of the most common causes of janky UI or unnecessary API calls in production frontends.
The pattern behind the patterns
These five problems won't prepare you for every technical interview. But the patterns they illustrate will show up in your production code whether you recognize them or not. The difference between a developer who reaches for a nested loop by default and one who instinctively asks whether a hash map would be faster isn't intelligence. It's pattern recognition, built through deliberate practice and understanding the why behind a solution, not just the how.
The same applies to debounce, binary search, flattening, and validation. Every one of these patterns has saved real time and prevented real bugs in production codebases I've actually shipped. Knowing them means recognizing the problem the moment it shows up in a different shape than the one you first learned it in.
If you've hit one of these patterns in a real production codebase and solved it differently, I'd genuinely like to hear how. Reach me at allen@formgrid.dev.