Full-Stack Developer (Entry Level) Interview Preparation Guide for Lyft
Lyft's interview process for entry-level software engineering roles typically consists of an initial recruiter screening, followed by one technical phone screen, then 4-5 onsite rounds combining coding assessments, system design fundamentals, behavioral evaluation, and Lyft-specific culture fit. The entire process evaluates technical fundamentals, problem-solving ability, communication skills, and cultural alignment with Lyft's mission to improve urban transportation.
Interview Rounds
Recruiter Screening
What to Expect
Initial conversation with recruiter covering your background, interest in Lyft, basic technical qualifications, and career goals. This round combines the initial recruiter screen and any recruiter follow-up conversations. Recruiter verifies you meet minimum requirements (relevant coursework, projects, or internships), assesses communication skills, and explains the interview process and Lyft's work culture.
Tips & Advice
Have a clear 2-minute self-introduction ready covering your background, why you're interested in full-stack development, and why Lyft specifically. Research Lyft's business (ridesharing, driver/rider dynamics, logistics challenges) and mention something that excites you. Be genuine about your career goals and learning interests. Ask thoughtful questions about the team, tech stack, and what success looks like in the first 90 days. Have your availability ready for upcoming rounds.
Focus Topics
Questions to Ask Recruiter
Prepare 3-4 thoughtful questions about the team structure, tech stack used, current projects, onboarding process, and what success looks like in the first months. Shows you think like an engineer and care about fit.
Practice Interview
Study Questions
Relevant Technical Experience Summary
Prepare 2-3 specific projects or coursework demonstrating both frontend and backend skills. For each: what you built, technologies used, your specific contributions, and outcomes. Include at least one full-stack project where you handled UI, backend logic, and database.
Practice Interview
Study Questions
Self-Introduction and Background Story
Craft a compelling 2-minute narrative covering your journey into software development, key projects or internships, and relevant skills. Structure: Who you are → Why you started coding → Relevant experience → Why full-stack interests you → Why Lyft.
Practice Interview
Study Questions
Lyft Company Knowledge and Motivation
Understand Lyft's business model (ridesharing platform), key products (Lyft rides, Lyft Bikes, Lyft Scooters), competitive landscape, recent initiatives, and engineering challenges specific to transportation/logistics. Articulate genuine reasons for wanting to work there beyond salary.
Practice Interview
Study Questions
Technical Phone Screen
What to Expect
First technical evaluation conducted via phone or video call with an engineer. Typically 1 coding problem focusing on fundamental data structures and algorithms (arrays, strings, basic trees, sorting, searching). Problem difficulty is easy to medium for entry-level. You'll write code in a shared editor (CoderPad, HackerRank, or similar), talk through your approach, and explain your solution. Time pressure and correctness matter less than problem-solving process and communication.
Tips & Advice
Talk through your thinking out loud—silence makes interviewers uncomfortable and they can't assess your thought process. Start by clarifying the problem and asking about edge cases before coding. Think about time/space complexity but don't obsess over it for easy problems. Write clean, readable code even if slow. If you get stuck, ask for hints—interviewers expect this at entry-level. Test your solution with sample inputs including edge cases (empty arrays, single elements, negative numbers). Practice on LeetCode Easy problems (arrays, strings, basic recursion). For entry-level, expect 1 problem, not 2. The interviewer is assessing if you can code fundamentally, not if you're a coding wizard.
Focus Topics
Code Quality and Debugging
Writing readable code with meaningful variable names, avoiding common bugs, testing your code with examples before submitting, and catching your own errors. Practice thinking step-by-step through your code.
Practice Interview
Study Questions
Basic Recursion and Problem-Solving
Simple recursive problems like factorial, fibonacci, tree traversal basics. Understanding when to use recursion vs iteration and basic problem decomposition.
Practice Interview
Study Questions
Arrays and Strings Manipulation
Problems involving searching, sorting, reversing, finding duplicates, or pattern matching in arrays and strings. Examples: Find if two strings are anagrams, remove duplicates from array, find intersection of two arrays, rotate array.
Practice Interview
Study Questions
Problem-Solving Communication
Articulating your approach before coding, explaining trade-offs (time vs space), discussing edge cases, and walking the interviewer through your logic. Practice saying 'Let me clarify the problem' and 'Here's my approach...' and 'Does this solution make sense?'
Practice Interview
Study Questions
Technical Onsite - Coding Round 1
What to Expect
First of multiple onsite technical interviews, typically 1-2 coding problems on medium difficulty level. Similar format to phone screen but with higher difficulty (e.g., medium LeetCode). Problems may touch both frontend concepts (DOM manipulation, JavaScript fundamentals) and backend concepts (database queries, API design basics). Conducted by a Lyft engineer in a conference room or virtual whiteboard. You're expected to think out loud, ask questions, and write working code.
Tips & Advice
Onsite raises difficulty bar slightly from phone screen. You're expected to be more independent—fewer hints given. For medium problems, explain your approach upfront including time/space complexity, then code. Handle edge cases proactively. If you make a mistake, catch it and fix it (shows attention to detail). Leave 5-10 minutes to test your code. Expect questions like 'Can you optimize this?' or 'What if the constraints changed?'—this shows problem-solving maturity. Interviewers assess depth of understanding, not just solution correctness.
Focus Topics
Whiteboarding and Code Communication
Writing pseudocode, drawing diagrams for complex problems, explaining your thought process clearly, asking for feedback, and being comfortable with imperfect solutions you can improve.
Practice Interview
Study Questions
Medium-Difficulty Data Structure Problems
Problems involving hash maps, linked lists, stacks, queues, or combinations thereof. Examples: LRU cache basics, two sum, valid parentheses, merge intervals, intersection of lists.
Practice Interview
Study Questions
Algorithm Complexity Analysis
Confidently articulating Big O time and space complexity, recognizing inefficient approaches, and optimizing solutions. Understanding trade-offs between different approaches.
Practice Interview
Study Questions
JavaScript/Language Fundamentals (if Full-Stack)
If using JavaScript, solid understanding of closures, callbacks, promises, async/await, scope, prototypes. If using another language (Python, Java), comparable fundamentals. String and numeric operations native to your chosen language.
Practice Interview
Study Questions
Technical Onsite - Frontend/UI Round
What to Expect
Onsite round focused on frontend-specific skills including HTML, CSS, JavaScript fundamentals, and UI component building. May involve building a simple interactive UI component (e.g., dropdown, autocomplete, pagination) or solving JavaScript coding problems specific to frontend (DOM manipulation, events, state management basics). Tests your ability to think about user experience, responsive design, and efficient client-side code. Conducted by a frontend-focused engineer at Lyft.
Tips & Advice
This round evaluates your frontend competency. You may be asked to build a working UI component from scratch—think aloud about structure (HTML), styling (CSS), and interactivity (JavaScript). Discuss accessibility and browser compatibility briefly if relevant. Know CSS basics (flexbox, grid, box model, positioning) and common gotchas (margin collapse, z-index stacking). Understand event handling and event delegation. For component problems, clarify requirements first (how should it behave? Mobile responsive?). Write semantic HTML. If using a framework like React, be prepared to build components, manage state, and handle props—but entry-level candidates may also be tested on vanilla JavaScript. Test your UI with the browser dev tools.
Focus Topics
Debugging and Developer Tools
Using browser dev tools (inspector, console, network tab), understanding console errors, debugging JavaScript (debugger statement, breakpoints), and testing UI changes in real-time.
Practice Interview
Study Questions
Simple Component Design and State Management
If using React: Building functional components, managing local state with hooks, passing props, basic component composition. If vanilla JS: Managing UI state, updating views when state changes, avoiding spaghetti code.
Practice Interview
Study Questions
JavaScript Fundamentals for Frontend
Closures, callback functions, promises, async/await, array methods (map, filter, reduce), destructuring, template literals, arrow functions. Understanding JavaScript scope and context ('this' keyword).
Practice Interview
Study Questions
HTML, CSS, and Responsive Design Fundamentals
Writing semantic HTML, CSS layout techniques (flexbox, grid), styling best practices, media queries for responsive design, cross-browser compatibility basics. Understanding box model, positioning, and common CSS pitfalls.
Practice Interview
Study Questions
JavaScript DOM Manipulation and Events
Selecting elements (querySelector, getElementById), modifying DOM (appendChild, innerHTML, classList), event handling (addEventListener, event delegation), and understanding event propagation (bubbling vs capturing). Vanilla JavaScript proficiency.
Practice Interview
Study Questions
Technical Onsite - Backend/API Round
What to Expect
Onsite round focused on backend and full-stack system thinking. May involve coding a simple REST API endpoint, database query problem, or backend logic problem. Tests your understanding of server-side code, database design basics (tables, relationships, queries), API design principles, and how frontend and backend communicate. For entry-level, expect practical questions rather than complex distributed systems. Conducted by a backend or full-stack engineer at Lyft. May include SQL basics or simple system design discussion (how would you design feature X?).
Tips & Advice
This round tests backend competency. If given a coding problem, think about data modeling first (what data do we need?). For API design, discuss endpoints, request/response formats, error handling. For database questions, understand basic SQL (SELECT, WHERE, JOIN, GROUP BY). Know when to normalize vs denormalize. Discuss scalability concerns briefly—entry-level candidates don't need complex answers, but should think about it. If asked about system design, focus on data flow and basic architecture rather than distributed systems. Discuss trade-offs (SQL vs NoSQL, caching, etc.) at a conceptual level. Be comfortable discussing the backend technology stack (could be Node.js, Python, Java, Go, etc.). Ask clarifying questions about constraints and requirements.
Focus Topics
Databases and Caching Strategy Concepts
Understanding when to use caching (Redis), database indexing basics, read/write optimization, and basic concepts like replication and sharding at a high level. Not implementation—just awareness.
Practice Interview
Study Questions
Authentication, Authorization, and Security Basics
Understanding basic concepts like session tokens, JWT, hashing passwords, SQL injection prevention, CORS, and HTTPS. Not deep dive—conceptual knowledge.
Practice Interview
Study Questions
Backend Logic and Problem-Solving
Writing server-side business logic, handling edge cases (null checks, validation), managing state on backend, and thinking through workflows. Similar to coding rounds but with backend-specific context.
Practice Interview
Study Questions
SQL and Database Fundamentals
Writing basic SQL queries (SELECT, WHERE, JOIN, aggregate functions), understanding schema design, primary/foreign keys, and basic normalization. Knowing when to use databases vs caching. Understanding the difference between relational and NoSQL databases.
Practice Interview
Study Questions
REST API Design and HTTP Basics
Designing REST endpoints (GET, POST, PUT, DELETE), status codes (200, 201, 400, 404, 500), request/response structures, error handling, and idempotency. Understanding query parameters vs path parameters vs request body.
Practice Interview
Study Questions
Behavioral and Culture Fit Round
What to Expect
Typically the final onsite round, sometimes conducted by a team lead, manager, or senior engineer. Focused on behavioral assessment, communication style, teamwork, learning ability, and fit with Lyft's culture and values. You'll be asked about past experiences, how you handle challenges, conflicts, feedback, and why you're interested in Lyft. This round evaluates soft skills and cultural alignment. May also include questions about the role itself and what you're looking for in your career.
Tips & Advice
Prepare 3-4 concrete stories about real experiences: (1) A technical problem you solved showing problem-solving skills, (2) A time you worked with teammates or got feedback showing collaboration and growth, (3) A time you failed or struggled and what you learned (shows humility and learning mindset), (4) A time you showed initiative or went above and beyond. Use the STAR method (Situation, Task, Action, Result) to structure stories—but keep them natural, not robotic. Research Lyft's stated values and mission; align your stories with them if genuine. Be authentic—interviewers can tell if you're faking it. Ask thoughtful questions about the team, growth opportunities, and engineering culture. For entry-level, interviewers expect enthusiasm to learn, curiosity, and collaborative spirit. Avoid negative talk about previous jobs or companies. Show genuine interest in Lyft's mission (improving transportation, helping both drivers and riders).
Focus Topics
Questions to Ask About Role and Team
Prepare thoughtful questions about the team structure, what success looks like in your first 6 months, growth opportunities, mentorship, technical direction, and team culture. Shows genuine interest and helps you evaluate fit.
Practice Interview
Study Questions
Teamwork and Collaboration
Story about working with teammates, handling disagreement with another developer, receiving critical feedback, or helping a teammate solve a problem. Emphasize listening, respect for others' ideas, and growth mindset.
Practice Interview
Study Questions
Learning and Growth from Failure
Story about a time you failed, made a mistake in code, or didn't understand something initially. Focus on how you recovered, what you learned, and how it made you better. Demonstrates humility and growth mindset.
Practice Interview
Study Questions
Problem-Solving and Dealing with Technical Challenges
Story about a technical problem you encountered (bug, algorithm challenge, understanding complex code), how you approached it, what resources you used, and what you learned. Shows problem-solving process and persistence.
Practice Interview
Study Questions
Lyft-Specific Motivation and Mission Alignment
Articulate why you specifically want to work at Lyft (not just 'it's a cool company'). Reference specific aspects: ridesharing problems, driver/rider experience, specific Lyft products, recent news, or engineering challenges. Show you understand the business and connect it to your values.
Practice Interview
Study Questions
Tell Me About Yourself and Your Background
Refined version of your recruiter pitch. 2-3 minute narrative covering your background, why you became a developer, key learning experiences, relevant projects, and where you want to grow. Should feel natural and conversational.
Practice Interview
Study Questions
Frequently Asked Full-Stack Developer Interview Questions
Implement a JavaScript function to validate whether a given string is a valid IPv4 or IPv6 address. For IPv4, each octet should be 0-255 with no leading zeros unless the octet is zero; for IPv6, validate eight groups of 1-4 hex digits, allowing shorthand '::' once. Discuss edge cases and complexity.
Sample Answer
Direct answer
Try IPv4 first: split on ., require exactly 4 parts, and each part must be all digits, at most 255, with no leading zero unless the part is exactly "0". If that fails, try IPv6: split on :, handle the :: zero-compression shorthand (which can appear at most once), require the expanded form to have exactly 8 groups, and each group must be 1 to 4 hexadecimal digits. Both formats have real edge-case density, which is exactly why this question is a good filter for carefulness rather than algorithmic cleverness.
Approach: IPv4
- Split on
.; reject unless there are exactly 4 parts. - Each part must be all-digit (test against
/^[0-9]+$/), which also rejects a sign character or empty part. - Reject a leading zero unless the part is exactly
"0"(i.e."0"is valid,"00"and"01"are not). - Reject if the numeric value exceeds 255.
Approach: IPv6
::can appear at most once; more than one is invalid (the shorthand would be ambiguous about how many zero groups it represents).- If
::is present, split the string into a "head" and "tail" around it, split each side on:(an empty side yields zero groups, not one empty-string group), and the total number of explicit groups on both sides together must be strictly less than 8 (the whole point of::is to stand in for at least one omitted group). - If
::is absent, splitting on:must yield exactly 8 groups. - Every resulting group must be 1 to 4 hexadecimal digits.
Complexity
Both: O(L) where L is the length of the input string (each character is inspected a constant number of times across the split and per-group checks).
Edge cases
- IPv4 with a leading zero (
"1.1.1.01"): rejected under the standard interview convention, discussed further below. - IPv6 with the
::shorthand at the very start or end ("::1","1::"), or standing alone for all-zero ("::"): all valid, and the head/tail split correctly produces zero groups on the empty side. - A string that looks numeric but isn't a valid address in either format (e.g. too many or too few groups): correctly rejected by both checks, so the overall answer is "neither."
function isValidIPv4(s) {
const parts = s.split(".");
if (parts.length !== 4) return false;
for (const p of parts) {
if (!/^[0-9]+$/.test(p)) return false;
if (p.length > 1 && p[0] === "0") return false;
if (parseInt(p, 10) > 255) return false;
}
return true;
}
function isValidIPv6(s) {
if ((s.match(/::/g) || []).length > 1) return false;
let parts;
if (s.includes("::")) {
const [head, tail] = s.split("::");
const headParts = head ? head.split(":") : [];
const tailParts = tail ? tail.split(":") : [];
if (headParts.length + tailParts.length >= 8) return false;
parts = headParts.concat(tailParts);
} else {
parts = s.split(":");
if (parts.length !== 8) return false;
}
for (const p of parts) {
if (p.length < 1 || p.length > 4) return false;
if (!/^[0-9a-fA-F]+$/.test(p)) return false;
}
return true;
}
const cases = ["172.16.254.1", "256.1.1.1", "1.1.1.1.1", "192.168.0.1",
"2001:0db8:85a3:0000:0000:8a2e:0370:7334", "2001:db8::8a2e:370:7334", "::1", "::"];
for (const c of cases) {
console.log(`${c} -> (${isValidIPv4(c)}, ${isValidIPv6(c)})`);
}
Output (executed, node s59_ip_validate.js):
172.16.254.1 -> (true, false)
256.1.1.1 -> (false, false)
1.1.1.1.1 -> (false, false)
192.168.0.1 -> (true, false)
2001:0db8:85a3:0000:0000:8a2e:0370:7334 -> (false, true)
2001:db8::8a2e:370:7334 -> (false, true)
::1 -> (false, true)
:: -> (false, true)
Each IPv4-shaped input is correctly recognized only as IPv4 ((true, false)), each IPv6-shaped input only as IPv6 ((false, true)), and the malformed 256.1.1.1 / 1.1.1.1.1 cases are correctly rejected as neither.
Trade-offs and pitfalls, grounded against a real parser
I checked this implementation's verdicts against Node's built-in net.isIP() (a mature, spec-driven parser shipped with the runtime this answer targets), as ground truth, over a wider test set that included two intentionally awkward cases. It surfaced one genuine, useful disagreement rather than confirming everything blindly:
const net = require("net");
console.log("1.1.1.01 -> mine:", isValidIPv4("1.1.1.01"), "net.isIP:", net.isIP("1.1.1.01"));
console.log("02001:0db8:85a3:0000:0000:8a2e:0370:7334 -> mine:", isValidIPv6("02001:0db8:85a3:0000:0000:8a2e:0370:7334"), "net.isIP:", net.isIP("02001:0db8:85a3:0000:0000:8a2e:0370:7334"));
console.log("::ffff:1.2.3.4 -> mine:", isValidIPv6("::ffff:1.2.3.4"), "net.isIP:", net.isIP("::ffff:1.2.3.4"));
Output (executed, node s59_parser_check.js):
1.1.1.01 -> mine: false net.isIP: 0
02001:0db8:85a3:0000:0000:8a2e:0370:7334 -> mine: false net.isIP: 0
::ffff:1.2.3.4 -> mine: false net.isIP: 6
- Leading zero and the oversized hex group: no disagreement here, and that itself is worth confirming rather than assuming.
net.isIP("1.1.1.01")returns0(invalid), agreeing with this implementation's rejection; the same is true for the 5-hex-digit IPv6 group. Leading-zero rejection is a deliberate, security-motivated convention: leading zeros in IPv4 octets are genuinely ambiguous across tools (some historically parsed them as octal, so"010"meant 8, not 10), and that exact ambiguity was serious enough that Python's own standard-libraryipaddressmodule was patched (CVE-2021-29921, fixed in Python 3.8.12 / 3.9.5 / 3.10.0a7) to reject any leading-zero octet outright rather than guess, precisely because parser disagreement on this point had been used to bypass IP-based access controls elsewhere. Node'snet.isIPhappens to already enforce the strict reading here, so this specific check doesn't surface a gap, but it would be a mistake to conclude every runtime's built-in parser is this strict; confirm it for whichever one you actually ship against. - A real disagreement:
"::ffff:1.2.3.4"(an IPv4-mapped IPv6 address, RFC 4291 section 2.5.5.2) is accepted bynet.isIPas valid IPv6 (6), but this implementation rejects it (false). The reason is structural, not a bug in the sense of violating the question's own spec: the question defines IPv6 validity as "eight groups of 1-4 hex digits, allowing shorthand::once," and"1.2.3.4"is neither a hex group nor handled by the::-splitting logic, so it correctly falls out of scope for THIS definition. A production-grade validator that needs to accept the full real-world IPv6 address space would need an explicit extra branch recognizing a trailing dotted-quad segment. This is a good example of why "matches a mature real parser" and "matches the question's stated spec" are two different bars, and a candidate should be explicit about which one their implementation is targeting. - A common bug in the IPv6 head/tail split: using
head.split(":")unconditionally, without thehead ? ... : []guard, on an empty head (e.g. for"::1") produces[""](one empty-string "group") instead of[](zero groups), which then incorrectly fails the later 1-to-4-hex-digit check on that phantom empty group. This applies identically in JavaScript and in a straightforward Python port of the same logic.
When is it appropriate to use WeakRef and FinalizationRegistry, and why do experts warn against relying on them for cleanup? Compare them with WeakMap for a long-lived cache.
Sample Answer
Direct answer
Use WeakRef and FinalizationRegistry rarely, and only for optimisation, never for correctness. A WeakRef is a reference that does not keep its target alive; a FinalizationRegistry asks the engine to call a callback some time after a registered object has been reclaimed. Appropriate uses: a cache whose values are expensive to rebuild and which may disappear at any time, and a backstop that logs or tidies up when code forgot to release something. Experts warn against relying on them because garbage collection (GC, the engine reclaiming memory nothing can reach) is not specified to run at any particular time, or at all, and a finalizer callback may be called late or never. MDN's own heading for both is "Avoid where possible".
What the documentation guarantees, and what it does not
- No promise of collection. MDN: a
WeakRef"might never returnundefinedfromderef", because the collector may never decide to reclaim the object; behaviour differs between engines, versions and even situations in one version. - No promise of the callback. A conforming engine is not required to call cleanup callbacks. A callback can be late or never, and is unlikely to run when the program shuts down entirely (closing a tab) or when the registry itself has become unreachable.
- Never synchronous. A finalizer always runs after the current job ends. A job is one uninterrupted run of JavaScript, started by the event loop (the scheduler that picks the next waiting task) and finished when the call stack is empty.
- Stable within one job. After
new WeakRef(obj)or a successfulderef(), the target stays alive until the end of the current job, so reclamation is only visible across event-loop turns. TwoWeakRefs to one target agree with each other within a job. - Ordering. If the target is also registered, the
WeakRefis cleared at the same time or before the callback runs, soderef()inside the callback returnsundefined. - The held value is strong.
register(target, heldValue, token)takes three arguments: the object to watch, the held value (the data passed to the callback when the target is reclaimed), and an optional unregister token (a key used later to cancel the registration). The registry keepsheldValuealive, so it must not reference the target or the target never dies. The unregister token is held weakly.
MDN's usage guidance: do not rely on cleanup callbacks for essential program logic; they may help reduce memory use over the course of a program and are unlikely to be useful otherwise.
Long-lived cache: WeakMap, Map, or WeakRef plus registry
In the table, angle brackets name types: WeakMap<object, V> is a weak map with object keys and values of some type V, and Map<string, WeakRef<V>> is a map with string keys whose values are weak references to a V.
| Need | Right tool | Why |
|---|---|---|
| Attach data to an object you already hold (metadata for a DOM node, a per-request record) | WeakMap<object, V> | Entry lives exactly as long as the key; no size or iteration, so GC timing is never observable and results stay deterministic |
Cache keyed by a string or id ("user:1") | Map<string, WeakRef<V>> plus a registry for housekeeping | A WeakMap cannot hold string keys; the WeakRef lets values vanish under memory pressure |
| Cache that must never lose an entry silently, or must be enumerable | Map with an explicit eviction policy (size limit, or time-to-live: the maximum age after which an entry is dropped and reloaded) | A Map holds keys and values strongly: nothing is ever collected, so an unbounded Map is the classic leak |
| Cache keyed by object where entries must expire when the key dies | WeakMap | Simplest; MDN notes the value stays as long as the key is alive, even if never read again |
A WeakMap is the answer whenever the entries' lifetime should equal a key object's lifetime. The WeakRef cache is for the narrower case where the lifetime should follow the value and the key is not an object.
// A string-keyed cache whose VALUES are held weakly (a WeakMap cannot do this: its keys must be objects).
class WeakValueCache {
#refs = new Map(); // key -> WeakRef<value>
#registry = new FinalizationRegistry((key) => {
// Housekeeping only: drop the dead entry, unless the key was re-filled since.
if (this.#refs.get(key)?.deref() === undefined) this.#refs.delete(key);
});
get size() { return this.#refs.size; }
get(key, load) {
const hit = this.#refs.get(key)?.deref();
if (hit !== undefined) return hit; // a miss is always possible: recompute
const value = load(key);
this.#refs.set(key, new WeakRef(value));
this.#registry.register(value, key); // held value is the key string, not the value
return value;
}
}
let loads = 0;
const cache = new WeakValueCache();
const load = (id) => { loads += 1; return { id, payload: new Array(1000).fill(id) }; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
let user = cache.get("user:1", load);
console.log("same object on second get:", cache.get("user:1", load) === user, "| loads:", loads);
user = null; // no strong reference left
await sleep(0); // a WeakRef target cannot be reclaimed before its job ends
globalThis.gc(); // --expose-gc, to make the demo repeatable
await sleep(50); // give the cleanup callback a later turn to run
console.log("entries left in cache:", cache.size);
cache.get("user:1", load);
console.log("loads after the target was reclaimed:", loads);
// An object-keyed association needs no cache class at all:
const metadata = new WeakMap();
let node = { tag: "div" };
metadata.set(node, { clicks: 0 });
console.log("WeakMap entry for a live key:", metadata.get(node));
// Native resource: the registry is only a backstop; close() is the real release path.
const registry = new FinalizationRegistry((name) => {
console.log("backstop ran for leaked handle", name);
});
class Handle {
#closed = false;
constructor(name) { this.name = name; registry.register(this, name, this); }
close() {
if (this.#closed) return;
this.#closed = true; // release the native resource here, deterministically
registry.unregister(this); // the backstop no longer needs to run
console.log("closed", this.name);
}
}
let h = new Handle("h1");
try { /* use h */ } finally { h.close(); }
let leaked = new Handle("h2"); // never closed: only the backstop can report it
h = null;
leaked = null;
await sleep(0);
globalThis.gc();
await sleep(50);
One sample run in node:22 (v22.23.3) prints (twelve consecutive runs printed identical output; collection timing is still not guaranteed):
same object on second get: true | loads: 1
entries left in cache: 0
loads after the target was reclaimed: 2
WeakMap entry for a live key: { clicks: 0 }
closed h1
backstop ran for leaked handle h2
How the program works:
WeakValueCache.getreturns the cached object ifderef()finds it, otherwise recomputes. Callers must treat every call as a possible miss, which is why aWeakRefcache stays correct only when the value can be rebuilt.- The registry callback only prunes dead map entries. It re-checks
deref()first so a key that has been refilled since is not deleted. The held value is the key string, not the value. - The demo drops its strong reference, yields one turn (
sleep(0)), forcesgc()(available because of--expose-gc; production code cannot do this), then waits for a later turn so the cleanup callback can run. After reclamationloadsbecomes 2 because the secondgethad to recompute. - The last block shows the backstop from both sides.
h1was closed, soclose()unregistered it and onlyclosed h1appears.h2was dropped withoutclose(), so after the forced collection the callback reports it. Deleting theregistry.unregister(this)line makesbackstop ran for leaked handle h1appear as well, which is how you can check that the unregister call is doing the work. - Without the forced
gc()there is no way to know when, or whether, the entry would disappear.
Releasing native resources owned by JS wrappers
Finalizers are the wrong primary mechanism for file descriptors, sockets, database connections or any native handle (memory held outside the JavaScript heap). Memory pressure on the JS heap triggers GC, but nothing triggers it because file descriptors are running out, so the handle can stay open for an unbounded time. Release deterministically: give the wrapper a close() method, call it in try/finally, and make it idempotent (calling it twice has the same effect as calling it once; the Handle class above ignores a second call). A file descriptor is the small integer the operating system hands a process to identify one open file or socket, and each process has a limited supply. A registry can sit behind that as a safety net that reports forgotten handles (registry.register(this, name, this): the first this is the watched object, name is the held value the callback receives, and the last this is the unregister token, so close() can cancel with registry.unregister(this)), and close() calls registry.unregister(this) so the backstop disappears once the real release happened. In the demo, h1 was closed and prints no backstop message, while h2 was never closed and does.
Pitfalls
- Reading
deref()once into a local variable and using that local keeps the object alive for the rest of the job: use the result, do not cache theWeakRefresult in a long-lived field. - Engines can hold extra references (closures, or inline caches, the engine's internal memo of where a property was last found, which can keep an object reachable), so "nothing references it" in your source does not mean the engine agrees.
- Do not build behaviour that depends on a finalizer having run (release a lock, flush data, update a counter shown to users).
- Tests that depend on
gc()need the flag and are fragile; test your code's behaviour on both a hit and a miss instead.
Running the code
Save the listing as cache.mjs (an ES module, no install needed).
docker run --rm --ulimit core=0 -v "$PWD":/w -w /w node:22 sh -c 'timeout 100 node --expose-gc cache.mjs'
Given an array containing only three distinct values, sort it in-place in a single pass using constant extra space (the Dutch National Flag problem). Then explain how the same three-way in-place partitioning idea generalizes to partitioning a list by an arbitrary predicate while keeping relative order stable.
Sample Answer
Direct answer
Sort the three-valued array in one pass with three pointers, low, mid, high, that carve it into a growing "already-placed-0s" region, a "confirmed-1s" region, and a "not-yet-placed-2s" region, swapping at most once per position and using no extra storage (the Dutch National Flag algorithm). The same low/high swap idea generalizes directly to a two-way in-place partition by any predicate, but that direct generalization is not stable: swapping elements out of place can scramble the relative order of items that land in the same output region, so keeping order stable needs an extra step beyond just relabeling the predicate.
Structured elaboration
The three-way invariant
At every point during the scan: everything before low is 0, everything in [low, mid) is 1, everything after high is 2, and mid is the next unclassified element. When nums[mid] == 0, swapping it to low and advancing both pointers is safe because the element that lands at mid from the swap was already known to be 1 (it came from inside [low, mid)). When nums[mid] == 2, swapping it to high and only decrementing high (not advancing mid) is required because the element swapped in from the unexplored tail is unclassified and must still be examined.
Generalizing to an arbitrary predicate, and where stability breaks
Collapsing the three categories to two, "satisfies the predicate" versus "does not", turns this into the standard in-place two-way partition: scan with a single pointer, swap anything satisfying the predicate to the front. This preserves the O(n) time, O(1) space profile, but it is not stable: a swap moves an element across positions that may already hold other same-category elements, and nothing about the swap preserves their relative order. The demonstration below shows two items in the same category (both satisfying the predicate) ending up reordered relative to each other purely because of how the swaps landed, even though the partition itself (which items are in which category) is correct.
To keep relative order stable while partitioning in place, the pointer-swap approach is not enough on its own. Two honest options:
- Trade space for stability (simple): scan once, appending predicate-true and predicate-false items to two separate output lists in encounter order, then concatenate. This is O(n) time and stable by construction, at the cost of O(n) auxiliary space, no longer in-place.
- Keep O(1) space, pay in time (advanced): a genuinely in-place and stable partition is possible using a recursive divide-and-rotate scheme, partition each half of the array independently, then merge the two halves' boundary regions by rotating the "false" suffix of the left half past the "true" prefix of the right half. This is the same technique behind library-grade in-place stable partitions (for example, C++'s
std::stable_partitionis specified to do at most O(nlogn) swaps in the general case, or a single linear pass if it is allowed to allocate a temporary buffer). It is a materially different algorithm from the plain DNF swap, not just the same three-pointer idea relabeled.
Worked example
Approach
First, the DNF sort itself:
def dutch_national_flag(nums):
low, mid, high = 0, 0, len(nums) - 1
while mid <= high:
if nums[mid] == 0:
nums[low], nums[mid] = nums[mid], nums[low]
low += 1
mid += 1
elif nums[mid] == 1:
mid += 1
else: # nums[mid] == 2
nums[mid], nums[high] = nums[high], nums[mid]
high -= 1
return nums
print(dutch_national_flag([2, 0, 2, 1, 1, 0]))
This prints [0, 0, 1, 1, 2, 2].
Second, the two-way generalization by predicate, showing the instability directly on tagged items so reordering is visible:
def unstable_partition(items, pred):
lo = 0
for i in range(len(items)):
if pred(items[i]):
items[lo], items[i] = items[i], items[lo]
lo += 1
return items
labeled = [('a', 1), ('b', 0), ('c', 1), ('d', 0), ('e', 1)]
print(unstable_partition(labeled[:], lambda t: t[1] == 1))
def stable_partition(items, pred):
true_bucket = [x for x in items if pred(x)]
false_bucket = [x for x in items if not pred(x)]
return true_bucket + false_bucket
print(stable_partition(labeled[:], lambda t: t[1] == 1))
This prints:
[('a', 1), ('c', 1), ('e', 1), ('d', 0), ('b', 0)]
[('a', 1), ('c', 1), ('e', 1), ('b', 0), ('d', 0)]
The swap-based version reorders 'b' and 'd' relative to each other (both are category-0, and 'd' ends up before 'b', the reverse of their original order), while the extra-buffer version preserves 'b' before 'd' exactly as encountered.
Key points
- The DNF three-pointer scan and the two-way predicate partition are the same swap mechanism; only the number of categories changes.
- Correctness of which bucket each item lands in is unaffected by the instability; only the order within a bucket is at risk.
- Stability and true in-place (O(1) space) are in tension for this problem: you can have both only by accepting O(nlogn) time via the rotation-based approach, or you can have O(n) time by giving up in-place-ness.
Complexity
DNF: time O(n) (single pass, mid never revisits a position), space O(1).
Unstable two-way partition: time O(n), space O(1).
Stable partition via extra buffer: time O(n), space O(n).
Edge cases
- Empty array, single element, or an already-sorted array: the DNF loop terminates immediately or after trivial no-op passes.
- All elements identical (all
1s, for instance):lownever advances,midsweeps straight tohighwith no swaps. - A predicate that is trivially true or false for every element: the two-way partition degenerates to a no-op copy.
Trade-offs & pitfalls
The most common mistake when moving from three-way DNF to a two-way predicate partition is assuming the result is automatically stable "because it's the same kind of algorithm." It is not, and that distinction matters directly whenever a partition needs to preserve, for example, insertion order or timestamp order within each output group (a very common real requirement, such as partitioning a task queue into "ready" versus "blocked" while keeping each group's original ordering). Reaching for the swap-based version there without checking the stability requirement is a silent correctness bug, not just a style choice. When stability genuinely is not required, the swap-based version remains the better default: no allocation, single pass, and it generalizes cleanly to k-way partitioning by widening from two pointers to k−1 boundary pointers.
Design a benchmark harness to measure latency (p50/p95/p99) and throughput of an RPC endpoint. Specify warm-up behavior, concurrency patterns, request distributions, how to account for noise and client-side bottlenecks, and how you'd report reproducible results for an SLO of 50ms p99.
Sample Answer
Framing
Benchmarking against a 50ms p99 SLO (service-level objective, the latency bound the
service commits to) means the harness has to be trustworthy specifically in the tail,
not just on average: most of the design decisions below exist to keep client-side and
measurement noise from contaminating exactly the percentile that matters.
Warm-up behavior
Run a fixed warm-up phase (time-based or request-count-based) before the measurement
window starts, and discard it entirely: connection pools need to fill, any JIT-compiled runtime (one that compiles code to machine code while it runs,
rather than ahead of time, so its first executions of a code path are slower) needs
its hot paths compiled, and DNS/TLS handshake caches need to populate.
Comparing "cold" and "warm" traffic in the same window would inflate the measured p99
with costs that real, sustained traffic never actually pays per request.
Concurrency patterns and request distributions
Model concurrency as closed-loop (a fixed number of virtual clients, each waiting for
its previous response before sending the next) or open-loop (requests arrive at a
target rate regardless of whether prior requests finished) deliberately, not by
accident, since they behave very differently once the service starts to queue: a
closed-loop model self-throttles (a slow response delays the next request from that
client), which can hide the same amount of overload that an open-loop model would
reveal as compounding queue growth. For an SLO that describes real user or
service-to-service traffic, open-loop generation matching the expected real arrival
pattern (steady rate, or bursty if that matches production) is the more honest
representation.
Accounting for noise and client-side bottlenecks
Run the load generator on separate, dedicated hardware from the service under test, and
verify the generator itself isn't the bottleneck by checking its own CPU/network
utilization during the run; a saturated load generator produces an artificially smooth,
capped latency distribution that looks like the service is performing better than it
is. Also pin the generator's own connection count and keep-alive settings explicit
rather than relying on client-library defaults, since those defaults vary and directly
gate achievable concurrency.
Measuring throughput, not just latency
Report throughput as the achieved rate of successful responses over the measurement
window (successful completions divided by window duration), and report it separately
from the offered rate the generator was configured to send. Under open-loop generation
those two numbers are equal only while the service is keeping up: once it saturates,
the offered rate stays at whatever it was configured to be while achieved throughput
flattens and the backlog (and therefore latency) grows, so a run that reports only the
configured rate claims a throughput the service never actually delivered. Count errors
and timeouts separately and exclude them from the success-throughput figure, since a
service that sheds load by failing fast can otherwise post an excellent p99 and a
healthy-looking request rate that is mostly error responses. The triple worth reporting
at every load level is therefore (achieved successful requests per second, error rate,
p50/p95/p99): a 50ms p99 is only a meaningful result at a stated, actually-delivered
throughput, and the pair of curves (offered rate on the x-axis, achieved throughput and
p99 on the y-axis) is what locates the knee where the SLO starts to break.
Reporting reproducible results against the 50ms p99 SLO
Report p50/p95/p99 (and ideally p99.9) together, not p99 alone, since the relationship
between them tells you whether a p99 miss is a broad tail problem or a small number of
severe outliers. State the exact concurrency level, request rate, payload shape, warm-up
duration, and measurement-window duration alongside the numbers, since a p99 figure
without those parameters cannot be reproduced or compared against a future run.
Summarize across multiple independent runs (median of several runs' p99, with the
spread across runs reported), rather than trusting a single run's p99, which is
statistically the noisiest of the percentiles being reported (it is estimated from the
fewest data points in the tail).
Trade-offs & pitfalls
A closed-loop harness run at a concurrency level below what the service can actually
sustain will report a comfortable p99 that has nothing to do with real behavior under
open-loop arrival at the same nominal rate; always sanity-check by increasing load
until the SLO breaks, not only by confirming it holds at one chosen level. Coordinated
omission is the specific, well-known failure mode here: in a closed-loop test, a slow
response delays when the next request is even issued, so the slow tail never gets fully
sampled and the reported percentiles under-represent how bad the real tail is;
correcting for it (or using an open-loop generator that issues requests on schedule
regardless of prior completion) is necessary for the p99 number to mean what it claims.
Given orders(order_id, created_at TIMESTAMP), explain why filtering March 2024 with created_at BETWEEN '2024-03-01' AND '2024-03-31' can miss rows, since created_at includes a time-of-day. Write a correct, index-friendly query using a half-open range instead.
Sample Answer
A TIMESTAMP column includes a time-of-day component, so a date-only BETWEEN upper bound like '2024-03-31' is treated as midnight at the start of that day, silently excluding every row later that same day; a half-open range using the next day's start avoids the gap.
Structured elaboration
-- Misses late-March-31 rows
SELECT order_id FROM orders WHERE created_at BETWEEN '2024-03-01' AND '2024-03-31';
-- Correct half-open range
SELECT order_id FROM orders WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01';
BETWEEN '2024-03-01' AND '2024-03-31' is shorthand for >= '2024-03-01' AND <= '2024-03-31', and that upper bound, when compared against a TIMESTAMP, is interpreted as 2024-03-31 00:00:00. Any row timestamped later that same day (say, 2024-03-31 23:00:00) is strictly greater than midnight and gets excluded, even though it clearly belongs in "March 2024" by any reasonable interpretation.
Worked example
Given orders at 2024-03-31 23:00:00 and 2024-03-15 10:00:00: the BETWEEN version returns only the March 15 order, silently dropping the March 31 order entirely. The half-open version (< '2024-04-01') correctly returns both orders.
Trade-offs and pitfalls
The half-open-range fix generalizes to any period boundary (a single day, a month, a quarter, a year): always express the upper bound as "the start of the next period, exclusive" rather than "the last moment of this period, inclusive", since the latter requires knowing the column's precision exactly (is it seconds? milliseconds? does '2024-03-31 23:59:59' actually cover a row at '2024-03-31 23:59:59.500'?) while the former never does. This same half-open form is also what makes the query index-friendly, which the question asks for by name: created_at >= '2024-03-01' AND created_at < '2024-04-01' compares the raw created_at column directly against two literal bounds, exactly the shape a B-tree index (the default index structure in most relational engines, storing column values in sorted order) on created_at can use for a plain range scan. A common alternative fix, wrapping the column instead of the bound, e.g. WHERE DATE(created_at) BETWEEN '2024-03-01' AND '2024-03-31' or CAST(created_at AS DATE) = ..., looks similarly correct but applies a function to every row's created_at value before comparing it, which prevents a plain index on created_at from being used at all, since the engine can no longer look up sorted values directly and instead has to compute the expression for every row. Keeping the column bare on one side of a plain comparison is what index-friendly means in practice.
Compare OAuth 2.0, OpenID Connect (OIDC), and SAML for solving authentication and authorization problems. For each protocol explain primary use cases (e.g., web SSO, mobile apps, enterprise federation), how authentication statements are conveyed, and typical deployment considerations (mobile vs enterprise SSO). Provide criteria you would use to choose one protocol over the others.
Sample Answer
Direct answer
OAuth 2.0 is an authorization framework: it lets a user grant a third-party application scoped access to an API or resource without handing over a password. On its own it has no standard concept of "who logged in," only "what access was granted." OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0 that adds a standardized token proving who authenticated, not just what the app can now touch. SAML (Security Assertion Markup Language) is an older, XML-based protocol built specifically for browser-based single sign-on (SSO), most often used to federate identity into enterprise web applications. All three answer "who is this, and can we trust that claim across a network boundary," but they target different client types and eras of the web.
Structured elaboration
| OAuth 2.0 | OIDC | SAML | |
|---|---|---|---|
| Primary use case | Delegated authorization: "let this app read my calendar" | Web and mobile login (SSO): "let this app know who I am" | Enterprise SSO: federating identity into an organization's web apps |
| How the trust statement is conveyed | An access token authorizes API calls; the token itself does not certify who authenticated | A signed ID token (a JSON Web Token, or JWT) carrying claims such as sub, iss, aud, exp, and the time of authentication | An XML assertion containing an authentication statement, signed by the identity provider and posted to the application via a browser redirect |
| Typical deployment fit | Any client needing scoped API access: mobile apps, single-page apps, machine-to-machine calls | The modern default for new consumer and enterprise login integrations | Legacy and regulated enterprise software, where the identity provider is often an on-prem or cloud directory (for example Active Directory Federation Services, or an identity provider like Okta configured for SAML) that the buyer already standardized on |
Criteria for choosing between them:
- Building a login experience for a modern web or mobile app that also needs to call an API on the user's behalf: use OIDC. It sits on top of OAuth 2.0, so you get delegated authorization and a verified identity from the same flow.
- Only need delegated API access, with no identity concept for the calling app itself (a backend job reading a user's calendar): plain OAuth 2.0 is sufficient and simpler.
- Integrating with an enterprise's existing identity provider, and that provider or the target application only speaks SAML: you use SAML even though it is heavier to implement than a JWT-based approach, because the counterpart has no OIDC endpoint to talk to.
- Mobile deployment pushes the decision toward OIDC: SAML's browser-redirect-and-XML-post pattern is awkward inside a native app, while OIDC's authorization code flow was purpose-built for exactly that client type.
- Enterprise SSO deployment sometimes forces SAML regardless of preference: large enterprise buyers frequently standardize on a SAML identity provider for audit and compliance reasons, and the vendor's application may expose only a SAML integration point.
Worked example
Trace "Log in with Google," which demonstrates both the OAuth delegation layer and the OIDC identity layer built on top of it:
- The user clicks "Log in with Google"; the app redirects to Google's authorization endpoint requesting
scope=openid email profile. - Google authenticates the user through its own login screen and the user consents to the requested scopes.
- Google redirects back to the app with a short-lived authorization code.
- The app exchanges that code at Google's token endpoint for an ID token (the OIDC-specific artifact, a JWT) and an access token (the underlying OAuth artifact).
- The app validates the ID token's signature and claims (
issequals Google's issuer,audequals the app's own client id,exphas not passed) to learn who logged in, from thesubandemailclaims. - If the app also wants to read the user's Google Calendar, it uses the separate access token for that call. That is the original OAuth layer doing its job, distinct from step 5.
This split is exactly why using bare OAuth to implement login was a historical mistake, before OIDC existed: an access token alone does not certify identity (it authorizes calls to a specific API, and it isn't required to be a verifiable, self-contained token at all), so an app inspecting only an access token to decide "who is logged in" could be fooled by a token that was legitimately issued, just for a different purpose or audience. OIDC's ID token exists specifically to close that gap.
Trade-offs and pitfalls
- SAML assertions are XML-based and require careful canonicalization and signature validation. Implementing that by hand is a well-known source of signature-wrapping vulnerabilities; always use a maintained library rather than parsing and verifying the XML yourself.
- Treating an OAuth access token as proof of identity, instead of using OIDC's ID token, is the single most common protocol-selection mistake in this space; it works in testing and fails once a token issued for a different audience gets presented to the wrong service.
- Bridging protocols (a SAML-only enterprise identity provider fronting an OIDC-only application, or the reverse) is a common real integration need, but it adds an extra hop and an extra trust boundary; treat that bridge as its own design problem rather than assuming one protocol trivially substitutes for the other.
You made a lateral move at some point, into a different function within the same field, to broaden your experience. What motivated it, and what did you gain?
Sample Answer
Quick answer
Frame a lateral move as a deliberate capability-gap fill: name the specific gap your prior role couldn't close, what you actually did in the new function, and what you gained that you couldn't have gotten by staying put, then connect it forward to the role you're interviewing for now.
How to build it
The gap-fill frame
A lateral move reads as strategic, not restless, when you can name the specific thing you couldn't learn where you were. "I wanted to broaden my experience" alone is weak; "I could plan well but had never owned the operational side that plans depend on" is a real gap.
What to cover in the action beat
Treat the lateral role like any other STAR story (Situation, Task, Action, Result): name concrete responsibilities that were genuinely new to you, not just a change of title. If the day-to-day work barely changed, the lateral move doesn't prove much; the interesting material is the part that was unfamiliar.
Connecting it forward
End by tying the gained capability to the role in front of you. The lateral move should read as the reason you're now more ready for this role, not as a detour you're explaining away.
Worked example
Skeleton: "I was in [prior function] and moved laterally into [adjacent function] for [a period] because I could [do task A] but had never had to [do task B], and I wanted to own both ends of the problem. In the new role I was responsible for [one or two concrete new responsibilities], which meant learning [a specific skill or process] from the ground up, including a stretch where I had to [a concrete example, e.g. fix a recurring handoff error between two teams by rebuilding the process both sides used]. What I gained was [a specific capability] I couldn't have picked up by staying in my original function, and it's a big part of why I can now [connect to the target role]."
Filled illustration: "I was in a planning-focused role and moved laterally into an operations role for about a year, because I could design a plan but had never had to run one day to day, and I wanted to own both ends of the problem. In the new role I was responsible for coordinating the daily handoffs between two teams, which meant learning the operational scheduling process from the ground up, including a stretch where I had to fix a recurring handoff error between the two teams by rebuilding the process both sides used. What I gained was a real feel for where a plan actually breaks down in practice, not just on paper, and it's a big part of why I can now spot operational risk earlier when I'm the one doing the planning."
Trade-offs and pitfalls
The most common weakness is describing the lateral move as a title change with no real new responsibility, which makes it sound like a resume line rather than a growth story. A second is failing to name the gap that motivated the move in the first place, leaving the interviewer to wonder whether it was really a choice or just what was available. Skipping the forward connection turns a genuinely interesting story into a closed loop that doesn't help the interviewer see why it matters for this role.
Describe encapsulation in the context of service APIs and data models. Explain how exposing internal data structures (for example, database-schema details) in service responses can lead to production issues, and how stronger encapsulation would have prevented them. Include one example of an enforcement mechanism.
Sample Answer
Direct answer
Encapsulation for a service means clients see a deliberate, stable contract (the fields and behaviour the service promises) and never the internal representation behind it (table names, column names, database IDs, internal status codes, ORM objects). When a service serialises its database rows straight into API responses, every client silently couples to the schema, so an ordinary internal change such as a column rename or a table split becomes a breaking change in production, and fields that were never meant to leave the service leak out. The fix is an explicit response model that the service owns and maps to, with an automated check that stops internal types from reaching the wire.
How schema leakage causes production issues
Suppose an orders endpoint returns the ORM (object-relational mapping) entity directly:
{
"ord_id": 88213,
"cust_fk": 4411,
"status_cd": 3,
"tot_amt_cents": 12999,
"deleted_at": null,
"internal_fraud_score": 0.82,
"customer": { "id": 4411, "password_hash": "..." }
}
Each of those fields is a future incident:
- Renames break clients. A developer renames
tot_amt_centstototal_centsin a routine migration. The web app is redeployed the same day, but installed mobile app versions keep readingtot_amt_centsand show a blank or zero total until every user updates, which can take weeks. - Internal codes become a public contract. Clients hard-code
status_cd == 3to mean "shipped". The team later inserts a new status "packed" as 3 and renumbers the rest, and every client now shows the wrong state. - Sensitive data leaks.
internal_fraud_scoreand a lazily loadedcustomer.password_hash(the ORM fetches a related row, here the customer, only when code actually touches it rather than upfront, so merely serialising an order can silently trigger the query that pulls it in) were never meant to leave the service, but serialising the whole entity sends them anyway. - Write-side over-posting. If the same entity is also accepted as input, a client can send
"status_cd": 3or another privileged field and the framework will happily bind it (often called mass assignment). - Performance surprises. Serialising an entity walks its relationships (turning the object into JSON touches every related object, and each one that is lazily loaded triggers a fresh database query the first time it is touched, rather than being fetched upfront), so adding a new relationship to the model can suddenly trigger dozens of extra queries or a huge payload for every response.
- Physical IDs leak. Exposing sequential database keys lets anyone enumerate records and makes it painful to re-key or merge databases later.
The pattern behind all six: the service gave away the right to change its own internals.
What stronger encapsulation looks like
The same endpoint returning a contract the service owns:
{
"id": "ord_7Kq2Lm",
"customer_id": "cus_9Qa1",
"status": "shipped",
"total": { "amount": "129.99", "currency": "USD" }
}
- Stable external IDs (opaque strings) instead of database keys.
- Named enum strings ("shipped") instead of internal numeric codes.
- Only fields that clients need, chosen on purpose; nothing sensitive is reachable.
- A mapping function between the internal entity and this response model. When the column is renamed, one line in the mapper changes and the response is identical.
Applied to each earlier failure: the rename touches only the mapper (1), the enum stays "shipped" whatever the database stores (2), the fraud score and password hash are not on the response type so they cannot be serialised (3), requests bind to a separate input model that has no status field (4), the mapper reads only what it needs (5), and external IDs hide the physical key (6).
An enforcement mechanism
My choice is an architecture test in CI that forbids API handlers from returning or accepting persistence types. For example, in a Java service an ArchUnit rule states that classes in the ..api.. package may not depend on classes in the ..persistence.entity.. package; in Python, an import-linter contract does the same between the API module and the models module. A developer who takes the shortcut gets a red build, not a code-review debate.
Two complementary checks make it stronger:
- Contract checking: the response schema is written in an OpenAPI document (a machine-readable API description), responses are validated against it in tests, and a breaking-change diff tool runs on every pull request so removing or renaming a public field is flagged.
- Consumer-driven contract tests: each client publishes the fields it actually uses, and the provider's build fails if a change would break one of them.
Trade-offs and pitfalls
- Mapping boilerplate is the real cost. It is worth it on any API with external or independently deployed clients; for a throwaway internal admin tool it may not be.
- Mirror DTOs (data-transfer objects that copy every column one-for-one) give the look of encapsulation with none of the benefit. The response model should be designed from what clients need.
- Leaking through errors: raw database error messages ("duplicate key violates constraint orders_pkey") expose the schema just as effectively. Map errors to stable error codes too.
- Leaking through query parameters: letting clients sort or filter by arbitrary column names (
?sort=tot_amt_cents) couples them to the schema through the request instead of the response.
Write the JSON body a server should return for a failed POST /orders call, using the Problem Details for HTTP APIs format. Include type, title, status, detail, and instance from the standard, plus a machine-readable error_code and a correlation_id you add on top of it, and briefly say what each field is for.
Sample Answer
Direct answer. The body below follows RFC 7807's shape (type/title/status/detail/instance) and adds two fields the standard leaves out but any real API needs: a stable machine-readable code and a correlation id.
{
"type": "https://api.example.com/errors/validation-failed",
"title": "Request validation failed",
"status": 400,
"detail": "The 'quantity' field must be greater than 0, and 'email' is not a valid email address.",
"instance": "/orders",
"error_code": "ORDER_VALIDATION_FAILED",
"correlation_id": "req_8f2a1c9d",
"remediation": "Fix the fields listed in 'detail' and resubmit the request."
}
What each field is for.
type: a stable URI identifying this PROBLEM TYPE (not this occurrence); resolving it (or just reading it, most implementations do not require it to be a live, dereferenceable page) tells a developer or a client library "this is the kind of error where request validation failed," reusable across every endpoint that can fail this way.title: a short, generic summary of that problem type, identical every time this specific type occurs; changes to wording here are a documentation update, not a per-request detail.status: 400, repeated in the body because some client code and logging pipelines only see the JSON body, not the HTTP status line, especially once a response has passed through a proxy that logs bodies but not headers.detail: THIS occurrence's specifics, which fields failed and why; this is what actually varies request to request, unliketitle.instance: the specific resource path this occurrence happened against, useful when the same problemtypecan occur on multiple endpoints and you want to know exactly which call triggered it.error_code: the machine-readable string a client's error-handling code should actually branch on (if error_code == "ORDER_VALIDATION_FAILED"), sincetype/titleare meant to be more human-facing and are not guaranteed to be a tight enum a client can safely switch over.correlation_id: lets support or the client hand this exact value back to you, and you look up the full server-side log entry for the request instantly, instead of searching by approximate timestamp.
Trade-offs and pitfalls. Putting per-request specifics (like the exact invalid value a user typed) into title instead of detail breaks the standard's own intended use of title as a stable, cacheable label for the error TYPE, and makes it useless for grouping or aggregating errors by type later.
In plain JavaScript (ES6+), write a small function that attaches a click listener to a button element which toggles the 'is-active' class and the aria-pressed attribute. The handler should be removable later; show how you would register and then remove the listener cleanly for multiple buttons on the page.
Sample Answer
Direct answer
Use real <button> elements, store the state in aria-pressed, and make the class follow the attribute. The handler reads the current aria-pressed, flips it, writes it back as the string "true" or "false", and calls classList.toggle('is-active', pressed) with the second argument so the class can never drift out of step with the attribute. To make the listeners removable for any number of buttons, register them all with the same AbortController signal and return a function that calls abort(). An AbortController is a built-in object that hands out a signal, a token you pass to addEventListener as { signal }; calling abort() on the controller marks the signal as aborted, and the browser then removes every listener that was added with that signal.
The function
aria-pressed is part of ARIA (Accessible Rich Internet Applications), a set of attributes that tell assistive technology, such as screen readers, what a control is and what state it is in. The WAI-ARIA Authoring Practices guide (APG) describes the toggle button: a button with aria-pressed set to true when toggled on and false when off, whose label must not change when the state changes (the state is carried by the attribute, not by swapping "Mute" for "Unmute"). Space and Enter activate a button, which a native <button> gives for free. Assistive technology (AT) is software such as a screen reader that reads the page aloud or drives it without a mouse, and the "button role" is the ARIA role that announces an element to that software as a button; a native <button> carries the role automatically.
// toggle_buttons.mjs
import { chromium, firefox, webkit } from 'playwright';
import assert from 'node:assert/strict';
// Runs in the page. One AbortController owns every listener, so one call undoes them all.
function bindToggles(buttons) {
const controller = new AbortController();
for (const button of buttons) {
button.classList.toggle('is-active', button.getAttribute('aria-pressed') === 'true'); // start in sync
button.addEventListener('click', () => {
const pressed = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(pressed));
button.classList.toggle('is-active', pressed); // class always mirrors the attribute
}, { signal: controller.signal });
}
return () => controller.abort();
}
const html = `<button class="t" aria-pressed="false">Bold</button>
<button class="t" aria-pressed="false">Italic</button><button class="t" aria-pressed="true" >Under</button>`;
const state = (page) => page.evaluate(() =>
[...document.querySelectorAll('.t')].map((b) => b.getAttribute('aria-pressed') + '/' + b.classList.contains('is-active')).join(' '));
for (const [name, type] of Object.entries({ chromium, firefox, webkit })) {
const browser = await type.launch();
const page = await browser.newPage();
await page.setContent(html);
await page.evaluate(`window.unbind = (${bindToggles.toString()})(document.querySelectorAll('.t'))`);
assert.equal(await state(page), 'false/false false/false true/true'); // start-in-sync line worked
await page.evaluate(() => document.querySelectorAll('.t')[0].setAttribute('aria-pressed', 'true')); // other code drifts the attribute, class untouched
await page.click('.t >> nth=0'); // mouse click: must land on false/false, not flip the class blindly
await page.focus('.t >> nth=1'); await page.keyboard.press('Space'); // keyboard activation
await page.click('.t >> nth=2');
const on = await state(page);
await page.evaluate(() => window.unbind());
await page.click('.t >> nth=0'); await page.click('.t >> nth=1');
const off = await state(page);
assert.equal(on, 'false/false true/true false/false');
assert.equal(off, on); // unchanged after unbind
console.log(name, '| after clicks:', on, '| after unbind + clicks:', off);
await browser.close();
}
The harness runs bindToggles in a real page. In Playwright's selector syntax, .t >> nth=0 means "the first element matching .t" (nth=1 the second, and so on), and page.keyboard.press('Space') sends a real Space key press to whichever element has focus, which is why the script calls page.focus first. It first checks that the buttons start in sync (the third begins pressed, so it must already carry the class). It then simulates other code drifting the first button's attribute to "true" without touching its class, clicks that button with the mouse (the handler must read the attribute and land on false/false, which a blind one-argument class flip would not), activates the second with the Space key, clicks the third, calls the returned function, and clicks again. Each printed cell reads aria-pressed/has is-active:
chromium | after clicks: false/false true/true false/false | after unbind + clicks: false/false true/true false/false
firefox | after clicks: false/false true/true false/false | after unbind + clicks: false/false true/true false/false
webkit | after clicks: false/false true/true false/false | after unbind + clicks: false/false true/true false/false
The first button, whose attribute was drifted to true from outside, ends unpressed without the class; the second ends pressed with it; the third (which started pressed) ends unpressed without it; and after the remover runs the two further clicks change nothing. The assert.equal calls throw if any state differs: removing the forced second argument of classList.toggle, removing the start-in-sync line, or dropping { signal } each fails a run.
Why it is written this way
getAttribute('aria-pressed') !== 'true'reads the real current state from the DOM instead of a variable, so markup that starts witharia-pressed="true"works, and a second script changing the attribute cannot leave the handler out of date.String(pressed)writes the literal text"true"or"false", the two values the APG toggle pattern uses.toggleAttribute('aria-pressed')would be wrong, because it adds or removes the attribute and never writes either value.classList.toggle('is-active', pressed)with the forced second argument sets the class to match instead of flipping it blindly. The one-argument form flips regardless of the attribute, which is how class and attribute fall out of step if either was changed elsewhere.- The sync line at the top of the loop makes the class match any
aria-pressedthat was in the markup when the listeners were bound. - One
AbortControllerowns all the listeners. Without it you would need to keep each handler function (for example in aMapwhose keys are the buttons and whose values are their handler functions) and loop over that map callingremoveEventListener("click", handler)for each button. Passing{ signal }toaddEventListeneris the documented way to haveabort()remove a listener.
Pitfalls
- Use a
<button>, not a<div>. A<div>has no keyboard activation, no focus and no button role; you would have to addtabindex,role="button"and key handlers yourself. - Do not change the label. The APG says it is critical that a toggle's label does not change with its state. If the design needs the text to change ("Mute" to "Unmute"), drop
aria-pressedand use an ordinary button whose text carries the state. - Style from the attribute if you can.
button[aria-pressed="true"] { ... }removes the need for a class entirely. The class is kept here because the question asks for it and older stylesheets rely on it. - Removing listeners does not undo the state. After
abort()the buttons keep whatever state they had. Restore it separately if the component is reused. - Re-binding the same buttons without aborting first adds a second listener per button, and two flips cancel each other out. Call the remover before binding again.
Running the code
mkdir demo && cd demo
echo '{"type":"module"}' > package.json
npm i playwright@1.48.2
npx playwright install chromium firefox webkit
# save the listing(s) above as toggle_buttons.mjs, then run:
node toggle_buttons.mjs
The printed output comes from runs in the mcr.microsoft.com/playwright:v1.48.2-jammy container, where the browsers are already installed and the install step is not needed. Playwright's WebKit build is the WebKit engine, not Safari itself.
Want to create your own tailored preparation guide using our deep research?
Get Started for FreeInterview-Ready Courses
Visual-first, interactive, structured learning paths
Browse Full-Stack Developer jobs
AI-enriched listings across hundreds of company career pages
Explore Jobs