Lyft Full-Stack Developer (Junior Level) Interview Preparation Guide
Lyft's junior full-stack developer interview process typically consists of an initial recruiter screening, followed by technical phone screens covering coding and system fundamentals, and onsite rounds that include coding assessments, system design discussions (at junior level, focused on basic architectural patterns), and behavioral/cultural fit evaluation. The process is designed to evaluate both technical competency across frontend and backend stacks and alignment with Lyft's values around building reliable transportation platforms.
Interview Rounds
Recruiter Screening
What to Expect
Initial conversation with Lyft recruiter to verify background, discuss career goals, and assess cultural fit. This combined screening covers both initial recruiter contact and any recruiter follow-up conversations. Recruiter will ask about your interest in Lyft specifically, clarify your full-stack experience across frontend and backend, and identify any potential blockers (visa sponsorship, location constraints, notice period).
Tips & Advice
Prepare a concise 2-minute pitch about why you want to work at Lyft specifically. Reference something about Lyft's mission or product that resonates with you. Have clear examples of full-stack projects you've contributed to, even if you didn't own them entirely at junior level. Be honest about your experience level and eagerness to grow. Ask thoughtful questions about the team structure and what success looks like in the first 6 months.
Focus Topics
Background and Experience Narrative
Develop a coherent story of how you entered tech and why full-stack development appeals to you. For junior level, focus on learning trajectory and hands-on projects rather than leadership or seniority.
Practice Interview
Study Questions
Why Lyft? - Company Motivation and Fit
Articulate genuine reasons for joining Lyft, connecting your interests to their business domain (ridesharing, mobility, real-time systems) and company values.
Practice Interview
Study Questions
Full-Stack Project Overview
Prepare 2-3 concrete examples of projects where you've worked across frontend and backend, even in junior capacity. Be clear about what you built, what tech stack was used, and your specific contributions.
Practice Interview
Study Questions
Technical Phone Screen - Frontend Fundamentals
What to Expect
First technical interview conducted via video call, focused on frontend development fundamentals. Expect questions on HTML, CSS, JavaScript, and basic frontend problem-solving. You may be asked to build a simple interactive component, fix buggy code, or explain frontend architectural patterns. This is not a full coding interview but rather assesses your comfort with frontend basics and communication skills.
Tips & Advice
Review CSS fundamentals (flexbox, grid, box model) and JavaScript ES6+ features (arrow functions, destructuring, promises, async/await). Be prepared to code live or discuss code. For a junior-level role, interviewers expect you to ask clarifying questions and think out loud. If you get stuck, explain your approach and ask for hints—this is valued over perfect silence. Focus on writing clean, readable code rather than the most optimal solution.
Focus Topics
CSS and Responsive Design
CSS box model, flexbox, CSS grid, media queries, and basic responsive design principles. Understand when to use each approach and be able to build layouts from mockups.
Practice Interview
Study Questions
DOM Manipulation and Events
Understanding the DOM, event delegation, event bubbling vs. capturing, and how to interact with DOM elements from JavaScript. Search results confirm event bubbling/capturing and event delegation are tested topics.
Practice Interview
Study Questions
React Fundamentals
Component lifecycle, hooks (useState, useEffect, useContext), component composition, state management basics, and how React rendering works. Be comfortable writing functional components.
Practice Interview
Study Questions
JavaScript Core Concepts
Solid understanding of callbacks, promises, async/await, closures, scope, hoisting, event loop, and ES6+ syntax. Be able to explain these clearly and code simple examples.
Practice Interview
Study Questions
Technical Phone Screen - Backend Fundamentals
What to Expect
Second technical interview focused on backend development. Expect questions on server-side programming, APIs, databases, and backend problem-solving. You may build a simple API endpoint, write database queries, discuss REST API design, or solve algorithmic problems. Interviewers assess your understanding of how data flows from database through API to frontend.
Tips & Advice
Review your preferred backend language (Python, Node.js, Go, Java) with focus on building simple API endpoints and handling requests/responses. Understand basic database concepts (ACID properties, indexes, query optimization) and be able to write simple SQL queries. If given an algorithm problem, clarify the requirements, think out loud about your approach, and explain your time/space complexity. For junior level, the bar is fundamentals and communication, not perfection.
Focus Topics
Authentication and Authorization Basics
Understanding of how user authentication works (sessions, tokens, JWT), basic authorization concepts, and why security matters in APIs. Familiarity with common patterns like OAuth.
Practice Interview
Study Questions
Relational Databases and SQL Basics
Database design fundamentals, relationships (one-to-many, many-to-many), normalization basics, and ability to write SELECT, INSERT, UPDATE queries. Understand how to JOIN tables and filter data.
Practice Interview
Study Questions
Backend Language Fundamentals
Core concepts in your primary backend language: variables, functions, classes/objects, error handling, common libraries/frameworks. Be comfortable writing simple functions and explaining code.
Practice Interview
Study Questions
REST API Design and HTTP
Understanding HTTP methods (GET, POST, PUT, DELETE), status codes, request/response structure, and RESTful design principles. Be able to design simple API endpoints and explain when to use different HTTP verbs.
Practice Interview
Study Questions
Onsite Interview - Full-Stack Coding Challenge
What to Expect
In-person or video onsite round where you build a small full-stack feature from scratch within 2-3 hours. You may be given a specification (e.g., 'Build a ride booking component') and asked to implement both frontend (React component) and backend (API endpoints) to completion. You'll work on a laptop with your choice of tech stack and interviewers will evaluate code quality, architecture decisions, testing mindset, and how well the feature works end-to-end.
Tips & Advice
Before starting, ask clarifying questions about requirements, edge cases, and expectations. Prioritize a working solution over perfection—it's better to have an end-to-end MVP than 80% of a perfect implementation. Write clean, readable code with meaningful variable names. Break the problem into frontend and backend components and explain your approach. Test your code as you build. For junior level, interviewers value your problem-solving process, communication, and ability to build working features more than advanced optimization.
Focus Topics
Testing and Debugging Mindset
Testing your code as you build, debugging issues systematically, using browser/server tools, and thinking about edge cases. At minimum, manual testing of happy path and basic error cases.
Practice Interview
Study Questions
Database Schema Design
Modeling data appropriately for the feature: choosing table structure, defining relationships, handling primary/foreign keys. Avoiding common mistakes like denormalization without reason.
Practice Interview
Study Questions
Code Organization and Readability
Writing clean code with clear variable names, breaking logic into functions, avoiding deep nesting, and making code easy to understand. Structuring files logically.
Practice Interview
Study Questions
API Design and Implementation
Designing clean, functional API endpoints that frontend can consume. Choosing appropriate HTTP methods, status codes, and response formats. Handling error cases gracefully.
Practice Interview
Study Questions
Full-Stack Feature Implementation
Building a complete feature from specification to working code: designing database schema, building APIs, and creating UI components. Managing the flow of data end-to-end and ensuring all parts integrate.
Practice Interview
Study Questions
Onsite Interview - System Design (Foundations)
What to Expect
System design discussion focused on architecture fundamentals appropriate for a junior developer. You'll discuss how to design a system or feature, addressing questions like: 'How would you architect a real-time notification system?' or 'What technologies would you use for a video streaming service?' The goal is not expecting expert-level system design but assessing your understanding of basic architectural patterns, tradeoffs, and when to use different technologies. You'll be asked to think out loud, draw diagrams, and explain your reasoning.
Tips & Advice
Start by clarifying requirements and constraints (scale, latency, consistency needs). Think out loud about your approach rather than jumping to solutions. Draw simple architecture diagrams showing major components. Be ready to discuss tradeoffs (e.g., SQL vs. NoSQL, monolithic vs. microservices). At junior level, you're not expected to design Netflix-scale systems—focus on reasonable architecture for the given problem. Acknowledge limitations of your design and areas where you'd learn more. Interviewers value your reasoning process over the perfection of your design.
Focus Topics
Identifying Bottlenecks and Tradeoffs
Being able to think through a design and identify where problems might occur (single point of failure, overloaded component, slow queries). Discussing reasonable tradeoffs given constraints.
Practice Interview
Study Questions
Basic Scalability Concepts
Understanding horizontal vs. vertical scaling, load balancing, caching strategies, database replication, and why they matter. Be able to discuss these at a conversational level.
Practice Interview
Study Questions
API Architecture Patterns
Understanding REST vs. RPC, synchronous vs. asynchronous communication, queues, and basic event-driven architecture concepts. When to use each pattern.
Practice Interview
Study Questions
Database Technology Selection
Understanding when to use relational databases vs. NoSQL, consistency models (ACID vs. eventual consistency), and basic tradeoffs. Be familiar with common choices (PostgreSQL, MongoDB, Redis).
Practice Interview
Study Questions
Onsite Interview - Behavioral and Culture Fit
What to Expect
Final round focused on behavioral assessment, team collaboration, and alignment with Lyft's values. Interviewers will ask about past experiences working with teams, handling challenges, learning from failures, and your approach to problems. Questions follow a behavioral format asking you to describe specific situations, your actions, and outcomes. This round also assesses whether you're genuinely interested in Lyft's mission and whether you'd be a good teammate for the engineering organization.
Tips & Advice
Use the STAR method (Situation, Task, Action, Result) for behavioral questions. Prepare specific examples from past work or projects—junior developers should focus on teamwork, learning from mistakes, and growth mindset rather than leadership or major accomplishments. Search results confirm Lyft asks 'Why Lyft?' explicitly, so prepare a thoughtful answer connecting their mission to your interests. Ask genuine questions about the team, role, and culture to show authentic interest. Be authentic and humble about what you don't know yet.
Focus Topics
Communication and Problem-Solving Approach
Describing how you approach problems, ask clarifying questions, communicate with team members, and document your work. Showing thoughtfulness in your process.
Practice Interview
Study Questions
Growth Mindset and Learning Ability
Demonstrating curiosity about technologies you haven't used, how you learn new tools/frameworks, and your approach to continuous improvement. Being honest about gaps and enthusiasm for growth.
Practice Interview
Study Questions
Collaboration and Teamwork
Providing examples of working effectively with teammates, code reviews, asking for help when needed, and contributing to team success. For junior level, showing ability to be a good team member and learn from others.
Practice Interview
Study Questions
Overcoming Challenges and Learning from Failure
Describing specific technical or professional challenges you've faced, your approach to solving them, and what you learned. Emphasis on growth mindset and resilience rather than having all the answers.
Practice Interview
Study Questions
Lyft Mission Alignment and Why Lyft
Understanding Lyft's mission (reliable, accessible transportation and delivery services) and articulating why it resonates with you. Being able to discuss specific aspects of Lyft's business or culture that appeal to you.
Practice Interview
Study Questions
Frequently Asked Full-Stack Developer Interview Questions
A mobile detail screen always needs an object but only sometimes needs its large related collection, such as comments. How would you design a single endpoint so the client can get the object with or without the collection? Show a sample request and response and explain the trade-offs.
Sample Answer
Direct answer
Add an opt-in include query parameter to the detail endpoint. GET /v1/articles/7 returns the object plus a cheap commentCount. GET /v1/articles/7?include=comments&comments_limit=20 returns the same object plus a bounded first page of comments under an included key, with a link to fetch more. The collection is never embedded by default, never unbounded, and an unsupported include value is rejected with 400, not ignored.
Sample requests and responses
Run in a Node 22 container with no dependencies (docker run --rm -v "$PWD":/w -w /w node:22 node include.mjs), include.mjs:
// Run: node include.mjs (Node 22, no dependencies)
import http from 'node:http';
const article = { id: 7, title: 'Article 7', body: 'Lorem ipsum '.repeat(150) };
const comments = Array.from({ length: 12 }, (_, k) => ({ id: 700 + k, author: ['Ana', 'Ben', 'Chen'][k % 3], text: 'Nice post '.repeat(5).trim() }));
const SUPPORTED = new Set(['comments']);
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const send = (s, o) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(o)); };
if (u.pathname !== '/v1/articles/7') return send(404, {});
const include = (u.searchParams.get('include') || '').split(',').filter(Boolean);
const unknown = include.filter(i => !SUPPORTED.has(i));
if (unknown.length) return send(400, { error: 'unsupported_include', values: unknown }); // reject, never ignore silently
const out = { data: { ...article, commentCount: comments.length } }; // count is always present and cheap
if (include.includes('comments')) {
const raw = u.searchParams.get('comments_limit');
const asked = raw === null ? 20 : Number(raw);
if (!Number.isInteger(asked) || asked < 1) return send(400, { error: 'invalid_comments_limit' }); // 0, -1, 'abc', 2.5 are rejected
const limit = Math.min(asked, 50); // server-side cap on the collection
const items = comments.slice(0, limit);
out.included = { comments: { items, next: items.length < comments.length ? `/v1/articles/7/comments?after=${items.at(-1).id}` : null } };
}
send(200, out);
});
await new Promise(r => server.listen(0, r));
const base = `http://127.0.0.1:${server.address().port}`;
for (const q of ['', '?include=comments&comments_limit=2', '?include=likes', '?include=comments&comments_limit=-1']) {
const r = await fetch(base + '/v1/articles/7' + q); const t = await r.text();
console.log(`GET /v1/articles/7${q}\n -> ${r.status}, ${Buffer.byteLength(t)} bytes`);
console.log(' ' + t.replace(/"body":"[^"]*"/, '"body":"..."'));
}
server.close();
Output (the long body is elided by the script's print step):
GET /v1/articles/7
-> 200, 1865 bytes
{"data":{"id":7,"title":"Article 7","body":"...","commentCount":12}}
GET /v1/articles/7?include=comments&comments_limit=2
-> 200, 2114 bytes
{"data":{"id":7,"title":"Article 7","body":"...","commentCount":12},"included":{"comments":{"items":[{"id":700,"author":"Ana","text":"Nice post Nice post Nice post Nice post Nice post"},{"id":701,"author":"Ben","text":"Nice post Nice post Nice post Nice post Nice post"}],"next":"/v1/articles/7/comments?after=701"}}}
GET /v1/articles/7?include=likes
-> 400, 50 bytes
{"error":"unsupported_include","values":["likes"]}
GET /v1/articles/7?include=comments&comments_limit=-1
-> 400, 34 bytes
{"error":"invalid_comments_limit"}
With the 12-comment data set the cap of 50 cannot be seen. Changing length: 12 to length: 60000 and requesting comments_limit=500 returns a page of 50 comments, while comments_limit=-1, 0 and abc all return the same 400. Without the integer check, -1 would reach slice(0, -1) and return every comment but the last.
Design choices
- Opt-in, default off. The screen that does not need comments pays nothing.
commentCountis always returned, so the screen can show "12 comments" and a button without loading any. - Bounded by the server.
comments_limitmust be a whole number of at least 1 (anything else is a400) and is capped on the server (50 here) whatever the client asks. An "include everything" request on an article with 50,000 comments would otherwise become the new over-fetch.nextis a link to the rest of the list as its own endpoint (a sub-resource). Itsafter=701part is a cursor: a bookmark that says "continue after the item with id 701", which is the last comment already delivered. The app requests that URL when the user scrolls, and the server answers with the next comments after 701, so nothing is repeated or skipped even if new comments arrive meanwhile. - A separate
includedsection, not nesting insidedata. The primary object keeps one stable shape with or without the include. A compound document is one response that carries the main resource plus related resources in a separate section; for example the article indataand its comments inincluded, rather than comments mixed into the article's own fields. This mirrors the compound-document idea in JSON:API, a public REST convention whose specification also says a server that does not supportincludemust answer400 Bad Request, and that it must not return resources nobody requested. - Strict validation.
include=likesreturns400with the offending value. Silently ignoring it would let a typo ship and the screen would just show no likes.
Trade-offs
| Option | Round trips | Bytes when comments are not needed | Cache behaviour |
|---|---|---|---|
| Always embed comments | 1 | Large (always paid) | One URL per article |
| Two endpoints (article, then comments) | 2 when needed, 1 otherwise | Minimal | Each URL cached on its own; comments can have a shorter lifetime than the article |
Opt-in include (this design) | 1 either way | Minimal | One cache entry per include combination |
In the run above, the plain request is 1,865 bytes and the request with two embedded comments is 2,114 bytes. The 249-byte difference is the two comments plus their wrapper, and it grows with the limit.
- Cache-key growth. Each distinct
includeandcomments_limitvalue is a separate URL for a CDN (content delivery network) or HTTP cache. Offer a few fixed combinations (limit 20 only) so the hit rate stays high. - Different change rates. If the article changes weekly and comments change by the minute, embedding ties their freshness together: the cached composite is only as fresh as its most volatile part. When comments are very volatile, prefer the two-endpoint option.
- Latency. With
include, the server waits for the slowest of the two data sources before sending anything. If comments come from a slow service, make the client fetch them separately, so the article paints first. - GraphQL alternative. A query can ask for
article { title comments(first: 20) { text } }or leavecommentsout, which solves the same problem without a parameter convention, with the caching trade-offs of aPOSTendpoint.
Pitfalls
- Letting
includeaccept arbitrary nested paths (comments.author.friends), which turns a detail endpoint into a graph traversal. Support a short fixed list. - Returning
included.comments: []when the user is not authorized to see comments. Check authorization for included data the same way as for the primary resource. For example, if comments on a members-only article are visible only to subscribers, a non-subscriber requesting?include=commentsshould get403, or noincludedsection with a clear reason, not an empty list that looks like "no comments yet". - Forgetting the count: without
commentCountthe client has to load the collection to know whether one exists.
Explain the architectural difference between a RESTful API and an RPC-style API: how URLs are used, whether the HTTP verb carries meaning, statelessness, resource orientation, and what each style implies for caching and for how tightly the client and server are coupled. Give one situation where you would prefer RPC over REST in a backend service.
Sample Answer
Direct answer. REST models a system as a set of addressable resources you act on with a small, uniform set of HTTP verbs; RPC models a system as a set of named procedures you call directly (createOrder, cancelOrder), with the URL identifying the ACTION rather than a resource. Choose RPC when the operations you are exposing genuinely are not resource-shaped (a computation, a workflow trigger, a batch job kickoff) rather than forcing them into an artificial noun.
URL design. REST: POST /orders/{id}/cancel (a resource, acted on via a sub-action) or, more purely, a state change expressed as PATCH /orders/{id} with a status field. RPC: POST /cancelOrder with the order id in the body, where the URL itself names the verb.
HTTP verb usage. REST assigns meaning to the verb itself: GET always reads, DELETE always removes. RPC typically uses POST for nearly everything, since the "meaning" lives entirely in the URL's procedure name, not in which HTTP method was used; the HTTP verb becomes just a transport detail, not semantic information a client or an intermediary can reason about.
Statelessness. Both styles can be, and should be, stateless; this is not actually a distinguishing factor between them, statelessness is a property of how you design the API, independent of resource-vs-procedure framing.
Resource orientation. REST insists that everything be modeled as a resource, even things that are awkward as nouns (a "calculation" or a "search" is forced into being a resource you POST to create); RPC has no such constraint, an operation is simply named for what it does, which can be a more natural fit for a genuinely action-shaped API (a recommendation-generation service, an image-processing job).
Caching implications. Because REST's GET requests map directly onto HTTP caching semantics, a well-designed REST read endpoint gets CDN and browser caching essentially for free; an RPC-style API that funnels everything through POST (even reads) forfeits that entirely, since HTTP caching semantics are built around GET being safe and cacheable.
Client-server coupling. RPC tends to couple the client more tightly to the server's specific set of named operations (the client has to know the exact procedure name and its exact parameter shape); REST's uniform interface means a client that understands "resources + a few verbs" can reason about a NEW resource it has never seen before, without learning a new procedure name for it.
When to prefer each. Prefer REST for a public, resource-centric API (an e-commerce catalog, a user-management API) where caching and broad client tooling compatibility matter. Prefer RPC for an internal service exposing genuinely action-shaped operations (trigger a batch export, run a recommendation computation) where forcing the action into a resource-and-verb shape would be more awkward than useful, and where every caller is a service you control that can simply be told the procedure name and shape directly.
After a working meeting, write a concise summary (3-6 sentences) that captures the decision made, who owns each follow-up, the deadlines, and any question that is still open.
Sample Answer
Direct answer
Write a short summary right after the meeting that states the decision made, names an owner and deadline for each follow-up, and flags anything still unresolved, so nobody has to reconstruct what happened from memory a week later.
Structured elaboration
- State the decision first, in one sentence, even if it feels obvious right after the meeting; it stops being obvious within a day or two, especially for people who weren't in the room.
- List action items with an owner and a deadline each, not a bare to-do list; "someone should look into X" is not actionable, "Priya will check the vendor SLA by Thursday" is.
- Name what's still open, explicitly, rather than letting it quietly drop; a one-line "not yet decided: whether we notify customers proactively" prevents someone assuming it was implicitly settled.
- Send it promptly, ideally within the hour, while the details are fresh and before people have moved on to something else and stopped tracking it mentally.
- Keep it short. Three to six sentences is usually enough; a summary that's as long as a transcript won't get read.
Worked example
"Decision: we're moving the schema migration to next Tuesday's low-traffic window instead of doing it live this week. Action items: Priya to update the migration runbook by Monday EOD; Sam to notify the on-call rotation of the new window by Friday. Open question: whether we need a customer-facing heads-up, still deciding, will confirm by Wednesday."
Three sentences, one decision, two owned action items with deadlines, and one explicitly flagged open item.
Trade-offs and pitfalls
- The most common failure is writing a summary that lists what was discussed instead of what was decided; a meeting can generate a page of discussion and one real decision, and the summary should reflect that ratio.
- An action item without a named owner tends to silently not get done; if you can't name an owner in the summary, that's a sign the meeting didn't actually resolve who's responsible.
- Sending it too late (days later) defeats the purpose; by then people have already formed their own, sometimes conflicting, memory of what was agreed.
Explain lexical scoping and the scope chain. Take a small example with three nested functions and say, for each variable reference, which scope it resolves to and why.
Sample Answer
Direct answer
Lexical (static) scoping means a function's variables are determined by where the function is written in the source, not by where it is called. Each function (and block) creates a scope, and every scope keeps a reference to the scope it was written inside. That chain of references is the scope chain. To resolve a name, the engine checks the current scope, then the enclosing one, and so on out to the outermost scope (the global scope in a browser script; in Node, the module scope of the file, which is where level below lives), and uses the first match. If nothing matches, it throws a ReferenceError.
Three nested functions
const level = 'global';
function outer(a) {
const b = 'b from outer';
function middle(c) {
const d = 'd from middle';
function inner(e) {
const f = 'f from inner';
return [level, a, b, c, d, e, f];
}
return inner;
}
return middle;
}
const inner = outer('a from outer')('c from middle');
console.log(inner('e from inner'));
// Scope follows where a function is WRITTEN, not where it is CALLED
const who = 'global';
function makeReporter() {
const who = 'makeReporter';
return function report() {
return who;
};
}
function caller(fn) {
const who = 'caller';
return fn();
}
console.log(caller(makeReporter()));
// Lookup walks outward and stops at the first match; a missing name ends in a ReferenceError
function lookup() {
return typeof notDeclaredAnywhere + ' / ' + (() => {
try { return notDeclaredAnywhere; } catch (e) { return e.name + ': ' + e.message; }
})();
}
console.log(lookup());
// Scope chain is live: inner sees later changes to an outer binding
function counter() {
let n = 0;
const read = () => n;
n = 5;
return read();
}
console.log(counter());
Run output:
[
'global',
'a from outer',
'b from outer',
'c from middle',
'd from middle',
'e from inner',
'f from inner'
]
makeReporter
undefined / ReferenceError: notDeclaredAnywhere is not defined
5
Resolution for the return [level, a, b, c, d, e, f] line inside inner:
| Reference | Where it resolves | Why |
|---|---|---|
f | inner's own scope | declared there with const |
e | inner's parameter | parameters live in the function's scope |
d | middle, one level out | not in inner, found next |
c | middle's parameter | one level out |
b | outer, two levels out | not in inner or middle |
a | outer's parameter | two levels out |
level | the file's outermost scope (module scope in Node) | no function in the chain declares it |
The output shows the same thing as values: outer and middle have already returned, but inner still reads a, b, c and d, because its scope chain keeps the variables it uses alive. That retained chain is a closure.
Written position beats call position
makeReporter declares its own who. The returned report function is called from caller, which also declares a who, but the result is makeReporter: the lookup walks the chain from where report was written. A language with dynamic scoping would print caller; JavaScript does not.
Missing names and live bindings
lookup() prints undefined / ReferenceError: notDeclaredAnywhere is not defined: typeof of an undeclared name is the one safe probe (undefined), while reading the name throws. The last example (5) shows that the chain holds live variables, not copies: read closes over n, and the later n = 5 is what it sees.
Trade-offs and pitfalls
- A name found in an inner scope hides outer names of the same name (shadowing); you cannot reach the hidden outer one by name from there.
varhas function scope, whileletandconsthave block scope, so avarinside anifis visible in the whole function.- A closure keeps alive every variable that it, or another closure created in the same scope, refers to, so a long-lived callback can hold large values longer than expected. Engines such as V8 can free variables that no closure refers to, so do not assume the whole scope is retained.
thisis the one thing that does not follow lexical scope for ordinary functions; arrow functions do take it lexically.
Running the code
Install: none (Node 22, no packages)
Run: docker run --rm -v "$PWD":/w -w /w node:22 node lexical.js
Files: save the listing as lexical.js
A user reports that SELECT * FROM orders WHERE customer_id = 12345; returns no rows, but you know customer_id 12345 exists. List at least four distinct reasons this can happen and the SQL checks you would run to diagnose each.
Sample Answer
At least four distinct reasons a filter that "should" match can return zero rows: a type/format mismatch (customer_id stored as text with leading zeros or whitespace, compared to a bare integer), a NULL-related predicate elsewhere in the query silently dropping rows, an implicit filter from a JOIN turning into an INNER join, or simply querying the wrong schema, table, or environment (a replica lagging behind, or a staging database that looks identical to production).
Structured elaboration
A systematic debugging checklist, roughly in order of how cheap each check is:
- Confirm you're querying the right place.
SELECT current_database(), check the connection's schema search path, and confirm you're not accidentally pointed at a read replica that hasn't caught up yet. - Isolate the WHERE clause. Run
SELECT * FROM orders WHERE customer_id = 12345with no other joins or filters. If that alone returns nothing, the problem is in the predicate or the data, not elsewhere in the query. - Check for a type/format mismatch.
SELECT customer_id, typeof(customer_id) FROM orders LIMIT 5(or the engine's equivalent) to confirm the column isn't storing '12345 ' (trailing space) or '012345' as text, which won't equality-match a bare integer literal. - Check if a JOIN elsewhere in the real query silently filtered the row out. A LEFT JOIN followed by a WHERE clause on the joined table's columns behaves like an INNER JOIN (a separate, common trap).
- Confirm the row genuinely exists in this exact table, not a similarly-named one, and that no row-level security policy or view definition is filtering it out invisibly.
Worked example
Given orders(customer_id TEXT) with a single row '12345 ' (trailing space): WHERE customer_id = '12345' returns zero rows, while WHERE TRIM(customer_id) = '12345' correctly finds it, confirming a whitespace mismatch as one concrete instance of reason #3.
Trade-offs and pitfalls
The instinct to immediately rewrite the query is usually premature; the fastest path is isolating variables one at a time (drop joins, drop extra filters, check raw data) rather than guessing. This checklist format is itself a useful thing to state out loud in an interview: it signals systematic debugging instinct rather than one lucky guess.
A report generator currently scans the same task records multiple times to answer different summary questions, and the runtime is starting to hurt as the team grows. How would you reason about the bottleneck, and what would you change in the data flow so repeated lookups get faster without making the codebase hard to maintain?
Sample Answer
Bottleneck reasoning
A bottleneck is the part of the system that dominates total runtime. If the report generator scans the same task records over and over, each new question forces another full pass through the data. That scales poorly as the team grows.
5 scans * 100000 rows = 500000 row visits
1 scan * 100000 rows = 100000 row visits
Intuition: one pass is 5x less work in this example.
What I would change
I would switch the data flow from repeated scans to one ingestion pass that builds summary structures as it goes:
- counters for totals by status, owner, or date
- maps indexed by the fields used in reports
- one reducer function that updates all needed aggregates together
Then each summary question reads from the precomputed aggregates instead of touching the raw records again. That keeps queries fast and the code maintainable because the aggregation logic lives in one place.
Tradeoff
The main cost is a bit more memory for the summary maps. I would accept that tradeoff because it is much cheaper than rescanning the whole dataset repeatedly.
Example
If ten report questions all need ticket counts by team, I would build that count once during ingestion, then reuse it for every report instead of looping over the task table ten times.
Legal sign-off is going to take three weeks, but the team wants to ship in one. How do you manage that timeline without steamrolling legal's concerns?
Sample Answer
Direct answer
Treat "legal needs three weeks but the team wants one week" as a scope problem, not a speed problem. Split the release into what can ship without new legal review and what genuinely needs sign-off, then give legal a narrow, well-defined ask for the second piece instead of asking them to review everything faster. The team ships on time, and the risky piece launches on its own review-driven schedule.
Structured elaboration
Find out what is actually blocking legal
"Legal sign-off" is rarely one undivided review. Ask legal directly which specific elements are new or unreviewed, and which are unchanged from something already approved. Most releases are a mix, and the review clock usually belongs to a small fraction of the surface area.
Split the release along that line
Everything that reuses already-approved language, patterns, or flows ships in the one-week window. Anything net-new that legal has not seen goes behind a feature flag (a toggle that keeps new code hidden from users until you're ready to turn it on) and ships later, once sign-off lands, decoupled from the original deadline.
Reduce legal's per-item cost, do not just ask for speed
A vague "please review this flow" invites a slow, open-ended read. A redlined diff (a side-by-side markup showing exactly which words changed from the last approved version, like tracked changes) against previously-approved language, with a one-paragraph explanation of what changed and why, is something legal can turn around fast because the review surface is small and explicit.
Keep everyone honest about the split
Do not quietly ship around legal's concern and call it done. Tell legal what you are shipping now, what is gated, and why you drew the line there, and let them confirm or push back on the boundary itself, not just react to a missed deadline.
Worked example
A signup redesign is due in one week. It includes a new consent checkbox asking users to opt into sharing data with a third-party analytics partner, and the copy for that checkbox has never been reviewed (legal quotes three weeks because it touches data-sharing language that needs a compliance read). Everything else in the redesign, the new layout and the reworked field order, is unchanged from an already-approved pattern used elsewhere in the product.
The split: ship the redesign now using the existing, already-approved consent copy and opt-in behavior unchanged. Put the new third-party-sharing consent language and checkbox behind a flag, off by default. Send legal a one-page diff: exactly the new sentence, what data it covers, and why it is being added, instead of the whole signup flow. The redesign ships in the one-week window. The new consent copy ships later, whenever legal actually signs off, on its own timeline, without ever having blocked the rest of the release.
Trade-offs and pitfalls
A flag-gated split adds real overhead: someone has to remember to remove the flag, and a half-shipped feature can linger longer than planned if nobody owns closing the loop. It also only works when the risky piece is genuinely separable. If the new element is load-bearing, meaning the whole flow depends on it, forcing a split creates a worse product than waiting.
The biggest pitfall is doing the split unilaterally and only telling legal afterward. That reads as shipping around the reviewer even when the intent was reasonable, and it burns the relationship needed for the next time this happens. The senior move is proposing the boundary and getting legal's explicit agreement on it before the ship date, not after.
Describe how to store and retrieve a JavaScript object representing user preferences (e.g., { theme: 'dark', fontSize: 16 }) using localStorage. Include code that handles serialization and a safe read that tolerates missing or corrupted data.
Sample Answer
Direct answer
localStorage stores only strings, so write with JSON.stringify and read with JSON.parse inside a try block, then validate the parsed value field by field and fall back to defaults for anything missing, corrupted or out of range. Treat every storage call as fallible: reads can throw (storage blocked, or the stored text is not valid JSON) and writes can throw (QuotaExceededError is the exception the browser raises when the storage size limit is reached or storage is disabled), so keep an in-memory copy and let the app run without persistence. To avoid a flash of the wrong theme, apply the saved theme from a tiny inline script in <head> before the first paint (the first moment the browser draws the page), and listen for the storage event (the browser event that fires in the other open tabs of the same origin (scheme, host and port) when localStorage changes) so a change made in another tab updates this one.
The module
Design choices, in the order they matter:
- A versioned key (
prefs:v1). If the shape changes later, read the old key and migrate instead of misreading old data. Migrating means converting stored data to the new shape: for example, if a later version splitsfontSizeintofontSizeandlineHeight, the new code readsprefs:v2, falls back toprefs:v1when it is missing, copies the valid fields into the new shape, writesprefs:v2and removes the old key. sanitize()is the single gate.JSON.parsecan returnnull, a string, an array or an object with the wrong types; none of that may leak into the app. Unknown keys are dropped and each bad value falls back to its default, so a partly valid object keeps its valid fields.- Reads never throw. reading the
localStorageproperty can throw when storage access is blocked (the HTML specification has the getter throw aSecurityError, the exception for access the browser blocks, such as an opaque origin or a user who disabled site data),getItemitself can fail in some browsers, andJSON.parsethrows aSyntaxErroron truncated text. Both end in the samecatchthat returns defaults. - Writes report success.
setItemthrowsQuotaExceededErrorwhen the value cannot be stored (full, or storage disabled for the site, which MDN notes can happen in private browsing).savePrefsreturnsfalseand keeps the newest value in a module variable, whichloadPrefsprefers until a later write succeeds. The page keeps working for the rest of the session. - Cross-tab updates use the
storageevent. It fires in other documents that share the storage area, not in the one that wrote (MDN and the HTML specification), so a tab never reacts to its own write. The handler re-reads throughloadPrefs()so it applies the same validation. The checke.key === nullis there becauselocalStorage.clear()in another tab fires one event withkeyset tonull(the HTML specification's behaviour forclear()) instead of naming a key, and a cleared store should reset this tab to defaults too.
// prefs.js: persist a small preferences object in localStorage without ever throwing.
const KEY = 'prefs:v1';
const DEFAULTS = Object.freeze({ theme: 'light', fontSize: 16 });
function sanitize(raw) {
const out = { ...DEFAULTS };
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out;
if (raw.theme === 'light' || raw.theme === 'dark') out.theme = raw.theme;
if (Number.isFinite(raw.fontSize) && raw.fontSize >= 10 && raw.fontSize <= 32) out.fontSize = raw.fontSize;
return out; // unknown keys are dropped, bad values fall back
}
let memory = null; // newest prefs when the last write to storage failed
export function loadPrefs() {
if (memory) return sanitize(memory); // a failed write is newer than whatever storage holds
try {
const text = localStorage.getItem(KEY); // reading localStorage can throw SecurityError when storage is blocked
return text === null ? { ...DEFAULTS } : sanitize(JSON.parse(text)); // JSON.parse can throw SyntaxError
} catch {
return { ...DEFAULTS };
}
}
export function savePrefs(next) {
const clean = sanitize(next);
try {
localStorage.setItem(KEY, JSON.stringify(clean)); // QuotaExceededError or SecurityError
memory = null;
return true;
} catch {
memory = clean; // the session keeps working from memory
return false;
}
}
export function onPrefsChange(callback) { // fires when ANOTHER tab writes
const handler = (e) => {
if (e.storageArea === localStorage && (e.key === KEY || e.key === null)) callback(loadPrefs());
};
addEventListener('storage', handler);
return () => removeEventListener('storage', handler);
}
First paint without a flash
Module scripts run after the document is parsed, often after the first paint, so applying the theme there shows the default theme for a moment. A small classic inline script placed in <head> runs while the page is still being parsed, before the body is rendered, and sets data-theme on <html>; CSS selects on it (:root[data-theme=dark]). It repeats the minimum validation in a try; it is the first <script> inside the HTML string in the test listing below. If the site has a Content Security Policy (a response header that restricts which scripts the page may run) that forbids inline scripts, give the script a nonce (a random per-response token that the policy accepts) or list its hash (a digest of the script text) in the policy. The test checks that the attribute is already set when the first script in <body> runs.
The test
The test uses Playwright (a library that drives real browsers from Node) and runs in Chromium, Firefox and WebKit (Playwright's WebKit build, not Safari on a device). It checks the defaults, the round trip, five kinds of bad stored data, the head script, a write in a second tab reaching the first tab (and not echoing back to the writer), localStorage.clear() in the second tab resetting the first to defaults (the key === null event), storage whose methods throw SecurityError, a localStorage property that itself throws, recovery on the next successful write once storage works again (which drops the in-memory copy), and storage filled until setItem throws.
How the harder steps work: page.route('http://app.test/**', ...) answers requests to the made-up host app.test from memory (the HTML string and prefs.js), which gives the page a real origin so localStorage is available. The first-paint check relies on a plain <script> at the start of <body> that copies data-theme into window.themeAtBodyStart as it runs, which proves the head script had already set it. Blocked storage is simulated twice, then restored each time: first by replacing Storage.prototype.getItem and setItem with functions that throw a SecurityError, then by redefining window.localStorage as a getter that throws, which is where the HTML specification puts the SecurityError. Both runs assert the defaults on read, false from savePrefs and the in-memory value afterwards. The quota step writes strings of 1,048,576 characters under new keys until setItem throws, halves the chunk size and repeats down to one character, so storage ends as full as the loop can make it and the later savePrefs call has to fail.
import { chromium, firefox, webkit } from 'playwright';
import { readFileSync } from 'node:fs';
import assert from 'node:assert/strict';
const prefsJs = readFileSync(new URL('./prefs.js', import.meta.url), 'utf8');
// The inline script in <head> runs before the first paint; the body script records what it found.
const html = `<!doctype html><html><head>
<script>
try { var p = JSON.parse(localStorage.getItem('prefs:v1')); if (p && (p.theme === 'dark' || p.theme === 'light')) document.documentElement.dataset.theme = p.theme; } catch (e) {}
</script>
<style>:root{background:#fff} :root[data-theme=dark]{background:#111}</style></head>
<body><script>window.themeAtBodyStart = document.documentElement.dataset.theme || null;</script>
<script type="module">
import { loadPrefs, savePrefs, onPrefsChange } from '/prefs.js';
window.api = { loadPrefs, savePrefs, onPrefsChange };
window.seen = [];
onPrefsChange((p) => window.seen.push(p));
</script></body></html>`;
const serve = (ctx) => ctx.route('http://app.test/**', (route) => {
const u = new URL(route.request().url());
if (u.pathname === '/prefs.js') return route.fulfill({ contentType: 'text/javascript', body: prefsJs });
return route.fulfill({ contentType: 'text/html', body: html });
});
for (const bt of [chromium, firefox, webkit]) {
const browser = await bt.launch();
const ctx = await browser.newContext();
await serve(ctx);
const a = await ctx.newPage();
await a.goto('http://app.test/');
await a.waitForFunction(() => window.api);
// Round trip, defaults and validation.
const r1 = await a.evaluate(() => {
const { loadPrefs, savePrefs } = window.api;
const first = loadPrefs(); // nothing stored yet
savePrefs({ theme: 'dark', fontSize: 18 });
return { first, saved: loadPrefs() };
});
assert.deepEqual(r1.first, { theme: 'light', fontSize: 16 });
assert.deepEqual(r1.saved, { theme: 'dark', fontSize: 18 });
// Corrupted and hostile values.
const bad = await a.evaluate(() => {
const { loadPrefs } = window.api;
const out = {};
for (const [name, text] of [['truncated', '{"theme":"da'], ['wrongType', '"hello"'], ['null', 'null'], ['badFields', '{"theme":"neon","fontSize":"huge","extra":1}'], ['partial', '{"fontSize":20}']]) {
localStorage.setItem('prefs:v1', text);
out[name] = loadPrefs();
}
return out;
});
assert.deepEqual(bad.truncated, { theme: 'light', fontSize: 16 });
assert.deepEqual(bad.wrongType, { theme: 'light', fontSize: 16 });
assert.deepEqual(bad.null, { theme: 'light', fontSize: 16 });
assert.deepEqual(bad.badFields, { theme: 'light', fontSize: 16 });
assert.deepEqual(bad.partial, { theme: 'light', fontSize: 20 });
// First paint: the head script has already set data-theme when the body starts.
await a.evaluate(() => window.api.savePrefs({ theme: 'dark', fontSize: 18 }));
await a.reload();
assert.equal(await a.evaluate(() => window.themeAtBodyStart), 'dark');
// Another tab writes; this tab hears about it.
const b = await ctx.newPage();
await b.goto('http://app.test/');
await b.waitForFunction(() => window.api);
await b.evaluate(() => window.api.savePrefs({ theme: 'light', fontSize: 24 }));
await a.waitForFunction(() => window.seen.length >= 1);
assert.deepEqual(await a.evaluate(() => window.seen.at(-1)), { theme: 'light', fontSize: 24 });
assert.equal(await b.evaluate(() => window.seen.length), 0, 'the writing tab gets no storage event');
// clear() in another tab fires one event whose key is null; this tab resets to defaults.
const before = await a.evaluate(() => window.seen.length);
await b.evaluate(() => localStorage.clear());
await a.waitForFunction((n) => window.seen.length > n, before);
assert.deepEqual(await a.evaluate(() => window.seen.at(-1)), { theme: 'light', fontSize: 16 });
// Storage that throws on every call: reads fall back to defaults, writes report false, memory keeps the value.
const blocked = await a.evaluate(() => {
const names = ['getItem', 'setItem'];
const saved = names.map((n) => Storage.prototype[n]);
names.forEach((n) => { Storage.prototype[n] = () => { throw new DOMException('blocked', 'SecurityError'); }; });
const { loadPrefs, savePrefs } = window.api;
const defaults = loadPrefs();
const ok = savePrefs({ theme: 'dark', fontSize: 20 });
const fromMemory = loadPrefs();
names.forEach((n, i) => { Storage.prototype[n] = saved[i]; });
return { defaults: defaults.theme, ok, fromMemory };
});
assert.deepEqual(blocked.defaults, 'light');
assert.equal(blocked.ok, false);
assert.deepEqual(blocked.fromMemory, { theme: 'dark', fontSize: 20 });
// Storage works again: the next successful write clears the in-memory copy.
const recovered = await a.evaluate(() => ({ ok: window.api.savePrefs({ theme: 'light', fontSize: 12 }), loaded: window.api.loadPrefs() }));
assert.equal(recovered.ok, true);
assert.deepEqual(recovered.loaded, { theme: 'light', fontSize: 12 });
// The HTML specification puts the SecurityError on the localStorage getter itself, so also make the property access throw.
const noProperty = await a.evaluate(() => {
const saved = Object.getOwnPropertyDescriptor(window, 'localStorage');
Object.defineProperty(window, 'localStorage', { configurable: true, get() { throw new DOMException('blocked', 'SecurityError'); } });
const { loadPrefs, savePrefs } = window.api;
const defaults = loadPrefs();
const ok = savePrefs({ theme: 'dark', fontSize: 22 });
const fromMemory = loadPrefs();
if (saved) Object.defineProperty(window, 'localStorage', saved); else delete window.localStorage;
return { defaults, ok, fromMemory, restored: window.api.savePrefs({ theme: 'light', fontSize: 12 }) };
});
assert.deepEqual(noProperty.defaults, { theme: 'light', fontSize: 16 });
assert.equal(noProperty.ok, false);
assert.deepEqual(noProperty.fromMemory, { theme: 'dark', fontSize: 22 });
assert.equal(noProperty.restored, true);
// Quota: fill storage with a filler key until setItem throws, then check savePrefs reports false.
const quota = await a.evaluate(() => {
localStorage.clear();
let errName = null, n = 0;
// Fill with shrinking chunks until even a few bytes no longer fit.
for (let size = 1024 * 1024; size >= 1; size = Math.floor(size / 2)) {
for (;;) {
try { localStorage.setItem('filler' + n++, 'x'.repeat(size)); } catch (e) { errName = e.name; break; }
}
}
const i = n;
const ok = window.api.savePrefs({ theme: 'dark', fontSize: 14 });
localStorage.clear();
return { errName, ok, fillerKeys: i };
});
assert.equal(quota.ok, false);
console.log(bt.name(), JSON.stringify({ quotaErrorName: quota.errName, saveReturnedOnFullStorage: quota.ok, storageBlocked: { writeOk: blocked.ok, readBack: blocked.fromMemory }, propertyBlocked: { writeOk: noProperty.ok, readBack: noProperty.fromMemory } }));
await browser.close();
}
console.log('all preferences checks passed');
Output (the quota error name is what each engine throws when storage is full):
chromium {"quotaErrorName":"QuotaExceededError","saveReturnedOnFullStorage":false,"storageBlocked":{"writeOk":false,"readBack":{"theme":"dark","fontSize":20}},"propertyBlocked":{"writeOk":false,"readBack":{"theme":"dark","fontSize":22}}}
firefox {"quotaErrorName":"QuotaExceededError","saveReturnedOnFullStorage":false,"storageBlocked":{"writeOk":false,"readBack":{"theme":"dark","fontSize":20}},"propertyBlocked":{"writeOk":false,"readBack":{"theme":"dark","fontSize":22}}}
webkit {"quotaErrorName":"QuotaExceededError","saveReturnedOnFullStorage":false,"storageBlocked":{"writeOk":false,"readBack":{"theme":"dark","fontSize":20}},"propertyBlocked":{"writeOk":false,"readBack":{"theme":"dark","fontSize":22}}}
all preferences checks passed
Trade-offs and pitfalls
- Store only what is small and non-sensitive.
localStorageis synchronous (it blocks the main thread while it reads or writes), readable by any script on the origin, and sent nowhere automatically; never put tokens or personal data in it. - Do not trust the stored value because you wrote it. Users, extensions and older app versions edit it; the validation is the real defence.
- Feature detection by
typeof localStorageis not enough. The object can exist and still throw on use, which is why the code wraps the calls and does not just check for the property. - Write on change, not on every keystroke, and debounce sliders such as font size (wait until the user has stopped moving the slider for a short moment, then write once).
- Use a cookie when the server needs the value.
localStorageandsessionStorageboth stay in the browser and are never sent with requests, so neither helps here. A server-rendered page cannot readlocalStorage, so a theme needed in the first HTML response belongs in a cookie. - Complexity: each read or write is O(size of the JSON string), which here is a few dozen bytes.
Running the code
mkdir prefs && cd prefs # save prefs.js and test-prefs.mjs here
echo '{"type":"module"}' > package.json
npm i playwright@1.48.2 && npx playwright install chromium firefox webkit
node test-prefs.mjs
Run in the mcr.microsoft.com/playwright:v1.48.2-jammy container (Node 20), where the browsers are already installed.
What metrics are commonly used as autoscaling triggers, both reactive and predictive? Weigh the pros and cons of CPU, memory, request rate, end-to-end latency, and custom application metrics like queue length or pending jobs as autoscaler inputs.
Sample Answer
Direct answer
Reactive autoscaling scales based on what's happening right now (a metric crosses a threshold); predictive autoscaling scales based on a forecast of what's about to happen. Neither is inherently better; they answer different questions, and the metric you feed either one matters more than which mode you pick. The strongest signals are the ones closest to actual user-facing backlog or experience (request latency, queue depth) rather than machine-level resource stats (CPU, memory), which correlate with load but can lag or miss it entirely for I/O-bound services.
Structured elaboration
| Metric | What it measures | Strengths | Weaknesses |
|---|---|---|---|
| CPU utilization | Compute saturation on the instance | Simple, available everywhere, correlates well with compute-bound work | Noisy on short bursts; a poor signal for I/O-bound or network-bound services; can lag actual user impact |
| Memory usage | Memory pressure on the instance | Stable, slow-changing; catches memory-bound workloads and prevents out-of-memory failures | Changes too slowly to trigger timely scale-out for a fast spike; conservative thresholds lead to overprovisioning |
| Request rate (RPS/QPS, requests or queries per second) | Incoming demand volume | Direct measure of load; maps naturally to concurrency for stateless services | Doesn't capture that requests vary wildly in cost; needs to be split by endpoint or payload size to stay meaningful |
| End-to-end latency | What the user actually experiences | Aligned with the service level objective (SLO, the measurable target you've committed to, e.g. "P95 (95th-percentile) under 300ms"); can catch saturation that CPU/memory miss (e.g., a downstream dependency slowing down) | Reactive scaling on latency is inherently a lagging response, since latency has already degraded by the time it crosses a threshold; affected by factors outside the service's own control |
| Custom metrics (queue length, pending jobs) | Actual backlog of work waiting to be done | Often the most predictive signal available, since it reflects the work still outstanding, not just current resource pressure; enables precise "workers needed" math | Requires instrumentation; stale or delayed metric reporting produces bad scaling decisions; adds a dependency the autoscaler now trusts |
Reactive vs. predictive, and why the metric choice compounds. A reactive policy watching CPU will always be a step behind, because CPU rises only after load has already increased. A reactive policy watching queue depth is closer to real time, because a growing backlog is itself the leading indicator of "we're falling behind," not a downstream symptom of it. Predictive scaling (using historical traffic patterns or short-term forecasting to act before load arrives) reduces this lag structurally, but it only works well when it's built on a metric with a real predictable pattern (daily/weekly seasonality in request rate, for instance); it doesn't fix the fact that CPU is a noisy, lagging signal to forecast against in the first place.
Target-tracking math. A common reactive scaling pattern (used by cloud provider target-tracking policies and by Kubernetes' Horizontal Pod Autoscaler alike) scales replica count proportionally to how far the current metric is from target:
desired replicas=⌈current replicas×target metriccurrent metric⌉If 10 replicas are running at 80% average CPU against a 50% target:
⌈10×5080⌉=16The policy adds 6 replicas in this step. This same formula works for any metric with a sensible linear relationship to load (request rate, queue depth per worker), which is why the choice of which metric feeds it matters more than the formula itself: feed it a noisy or lagging metric and it will confidently compute the wrong target.
Worked example
A web-facing auto scaling group (ASG) targets 50% CPU with a 60-second scale-out cooldown and a 300-second scale-in cooldown, intentionally asymmetric: react to a spike quickly, but wait much longer before removing capacity. Without that asymmetry, consider what happens if CPU oscillates naturally between 45% and 65% around a single 50% threshold with equal cooldowns on both directions: the group scales out when CPU ticks above 50%, the added capacity immediately drags average CPU back below 50%, the group scales back in, CPU climbs again, and the fleet thrashes (adds and removes instances repeatedly) without ever settling. The fix has two parts: a hysteresis band (scale out above, say, 70%; scale in below, say, 30%, rather than a single shared threshold) so normal noise doesn't cross both boundaries, and a longer scale-in cooldown than scale-out cooldown, so the group is quick to protect against real load but slow and conservative about giving capacity back. That second part is the "safe scale-down" half of the policy: it's just as important as reacting fast to a spike, because a policy that scales in as aggressively as it scales out will flap on exactly the kind of noise a real production CPU curve always has.
Trade-offs & pitfalls
- Relying on a single metric is brittle: a service can be CPU-healthy while its queue backs up (I/O-bound work) or CPU-saturated while user latency is fine (CPU-bound but well within budget); combining two or three signals catches what any one alone would miss.
- Custom metrics add real value but add a real dependency: if the metric pipeline itself lags or goes stale, the autoscaler is making decisions on old data and can either overreact to a stale spike or fail to react to a live one.
- Symmetric cooldowns (same wait time for scale-out and scale-in) are a common oscillation trap; scale-in should almost always be more conservative than scale-out.
- Predictive scaling built on a metric with no real seasonal pattern (or one whose pattern just changed, e.g., after a product launch) will confidently mis-forecast; it needs the reactive layer as a backstop, not as a replacement.
A useEffect fetches data using a prop but keeps showing results for the previous prop value. Explain how the dependency list works, what bugs missing or extra dependencies cause, and how you would debug it.
Sample Answer
Direct answer
Stale results after a prop change (the screen shows data for an earlier input) have two possible causes that look identical on screen: the effect did not re-run (a missing dependency), or it did re-run but an older response arrived last and overwrote the newer one (a race condition, no cleanup). An effect is a function React runs after the screen has updated, used for work outside rendering such as fetching. The dependency list is the array of reactive values (props, state and anything declared in the component body) that the effect reads; after each render React compares every entry with its previous value using Object.is and re-runs the effect only if one differs. A correct fetch effect lists every value it reads, and returns a cleanup function that marks the old request as obsolete. Debug in this order: did the effect re-run (a new request in the Network panel)? If not, fix the dependencies. If it did, were the responses out of order? If so, add cleanup.
What the dependency list does
React's documentation: the dependencies are "the list of all reactive values referenced inside of the setup code," and "React will compare each dependency with its previous value using the Object.is comparison. If you omit this argument, your Effect will re-run after every commit of the component." (A commit is the step where React applies a render's result to the screen.) So there are three shapes:
[id]: re-run whenidchanges (what a fetch keyed on a prop needs).[]: run once after mount. If the effect readsid, it keeps using theidfrom the first render forever. This is the "closure" problem: a closure is a function together with the variables it captured where it was created, so a function created in a render captures that render's values, and an effect that never re-runs keeps seeing old ones (a stale closure).- No array: run after every render.
Reproducing each bug
The program below uses a fake API whose responses the test resolves by hand, so the order is controlled (no timers, no real network). It renders one component per failure mode. The first three components differ only in their dependency list and cleanup. Reading the harness: fetchItem returns a promise that stays unresolved until the test calls the matching function stored in pending[id], and it records every call in calls and every cancellation in aborted. act(...) from Testing Library runs the code inside it and applies the resulting React updates before returning, so the next line sees the updated screen; flush is a small helper that wraps a resolve call in act and waits one promise tick. mock.timers.enable from node:test replaces setInterval with a fake clock that only advances when the test calls mock.timers.tick(1000), so case 5 needs no real waiting.
import { JSDOM } from 'jsdom';
import { mock } from 'node:test';
import assert from 'node:assert/strict';
const dom = new JSDOM('<!doctype html><body></body>', { url: 'http://localhost' });
globalThis.window = dom.window; globalThis.document = dom.window.document;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
const React = (await import('react')).default;
const { render, act, cleanup, waitFor } = await import('@testing-library/react');
const { useState, useEffect, StrictMode } = React;
const h = React.createElement;
// Fake API: every call returns a promise the test resolves by hand, so response order is under control.
let calls, pending, aborted;
const reset = () => { calls = []; pending = {}; aborted = []; };
const fetchItem = (id, signal) => new Promise((resolve) => {
calls.push(id);
pending[id] = (v) => resolve(v);
signal?.addEventListener('abort', () => aborted.push(id));
});
// 1. Missing dependency: effect reads `id` but the list is []
function MissingDep({ id }) {
const [data, setData] = useState('loading');
useEffect(() => { fetchItem(id).then(setData); }, []); // bug: [] although id is used
return h('p', null, `id=${id} shows ${data}`);
}
// 2. Right dependency, no cleanup: responses can arrive out of order
function NoCleanup({ id }) {
const [data, setData] = useState('loading');
useEffect(() => { fetchItem(id).then(setData); }, [id]);
return h('p', null, `id=${id} shows ${data}`);
}
// 3. Right dependency + cleanup: ignore flag and AbortController
function Fixed({ id }) {
const [data, setData] = useState('loading');
useEffect(() => {
let ignore = false;
const ctrl = new AbortController();
setData('loading');
fetchItem(id, ctrl.signal).then((r) => { if (!ignore) setData(r); });
return () => { ignore = true; ctrl.abort(); };
}, [id]);
return h('p', null, `id=${id} shows ${data}`);
}
// 4. Extra dependency: an object created in render is a new value every time
let loopCalls = 0;
function ObjectDep({ id, objectDep }) {
const [data, setData] = useState(null);
const params = { id }; // new object on every render
useEffect(() => {
loopCalls++;
if (loopCalls > 25) return; // safety stop so the demo terminates
Promise.resolve({ id, fetchedAt: loopCalls }).then(setData); // a fresh object each time, so state changes
}, objectDep ? [params] : [id]);
return h('p', null, `fetches so far ${loopCalls}, data ${data ? data.id : '-'}`);
}
// 5. Stale closure: an interval created once reads `count` from the first render
const logged = [];
function StaleClosure({ deps }) {
const [count, setCount] = useState(0);
useEffect(() => { const t = setInterval(() => logged.push(`${deps}:${count}`), 1000); return () => clearInterval(t); },
deps === 'empty' ? [] : [count]);
return h('button', { onClick: () => setCount((c) => c + 1) }, 'inc');
}
const text = (c) => c.querySelector('p').textContent;
const flush = (fn) => act(async () => { fn(); await Promise.resolve(); });
// 1
reset();
let r = render(h(MissingDep, { id: 1 }));
await flush(() => pending[1]('data-1'));
r.rerender(h(MissingDep, { id: 2 }));
console.log('1 missing dep :', text(r.container), '| fetches:', calls.join(','));
assert.equal(text(r.container), 'id=2 shows data-1'); assert.deepEqual(calls, [1]);
cleanup();
// 2 and 3: id 1 is slow, id 2 is fast; responses arrive 2 then 1
for (const [name, C] of [['2 no cleanup ', NoCleanup], ['3 ignore + abort ', Fixed]]) {
reset();
r = render(h(C, { id: 1 }));
r.rerender(h(C, { id: 2 }));
await flush(() => pending[2]('data-2'));
await flush(() => pending[1]('data-1'));
console.log(name + ':', text(r.container), '| fetches:', calls.join(','), '| aborted:', aborted.join(',') || 'none');
if (C === NoCleanup) assert.equal(text(r.container), 'id=2 shows data-1');
else { assert.equal(text(r.container), 'id=2 shows data-2'); assert.deepEqual(aborted, [1]); }
cleanup();
}
// 3a: the reset line. After id 1 has loaded, switching to id 2 must not show data-1 while id 2 is pending
reset();
r = render(h(Fixed, { id: 1 }));
await flush(() => pending[1]('data-1'));
r.rerender(h(Fixed, { id: 2 }));
console.log('3a reset on change :', text(r.container), '| fetches:', calls.join(','));
assert.equal(text(r.container), 'id=2 shows loading');
cleanup();
// 3b: StrictMode runs setup, cleanup, setup again on mount
for (const [name, C] of [['3b StrictMode, Fixed ', Fixed], ['3b StrictMode, NoCleanup', NoCleanup]]) {
reset();
r = render(h(StrictMode, null, h(C, { id: 7 })));
console.log(name + ':', 'fetches:', calls.join(','), '| aborted:', aborted.join(',') || 'none');
assert.deepEqual(calls, [7, 7]); assert.deepEqual(aborted, C === Fixed ? [7] : []);
cleanup();
}
// 4
for (const objectDep of [true, false]) {
loopCalls = 0;
r = render(h(ObjectDep, { id: 5, objectDep }));
await waitFor(() => assert.ok(objectDep ? loopCalls >= 25 : loopCalls >= 1));
await act(async () => { await new Promise((res) => setTimeout(res, 50)); });
console.log(`4 ${objectDep ? '[params] object dep' : '[id] primitive dep '}:`, 'effect ran', loopCalls, 'times for one id');
if (objectDep) assert.ok(loopCalls > 25); else assert.equal(loopCalls, 1);
cleanup();
}
// 5
mock.timers.enable({ apis: ['setInterval'] });
for (const deps of ['empty', 'count']) {
r = render(h(StaleClosure, { deps }));
const btn = r.container.querySelector('button');
await act(async () => { btn.click(); btn.click(); }); // count becomes 2
act(() => mock.timers.tick(1000));
cleanup();
}
mock.timers.reset();
console.log('5 interval logs :', logged.join(' | '));
assert.deepEqual(logged, ['empty:0', 'count:2']);
console.log('all assertions passed');
Run in a node:22 container with the packages under "Running the code", it prints:
1 missing dep : id=2 shows data-1 | fetches: 1
2 no cleanup : id=2 shows data-1 | fetches: 1,2 | aborted: none
3 ignore + abort : id=2 shows data-2 | fetches: 1,2 | aborted: 1
3a reset on change : id=2 shows loading | fetches: 1,2
3b StrictMode, Fixed : fetches: 7,7 | aborted: 7
3b StrictMode, NoCleanup: fetches: 7,7 | aborted: none
4 [params] object dep: effect ran 26 times for one id
4 [id] primitive dep : effect ran 1 times for one id
5 interval logs : empty:0 | count:2
all assertions passed
The lines read as follows.
- Missing dependency (
[]although the effect readsid): the prop changed to 2 but the screen showsdata-1and only one request was ever made. This is the symptom in the question; the Network panel shows no second request. - Right dependency, no cleanup: two requests (ids 1 and 2). Id 1 was slow, id 2 fast, responses arrived 2 then 1, and the late response for the old prop overwrote the newer data:
id=2 shows data-1. The Network panel shows both requests and the older one finishing last. In real apps this appears on slow or flaky connections and when typing quickly in a search box. - Right dependency + cleanup: the screen shows
data-2. The cleanup of the previous effect setignore = true(so its.thendoes nothing) and aborted itsAbortController(an object whosesignallets the request be cancelled; the log shows request 1 was aborted). React'suseEffectpage shows this sameignorepattern and says it "ensures your code doesn't suffer from 'race conditions': network responses may arrive in a different order than you sent them."- 3a. Reset on change: once id 1 has loaded and the prop becomes 2, the
setData('loading')line makes the screen readid=2 shows loadingwhile request 2 is pending. Deleting that line leavesid=2 shows data-1on screen until request 2 answers, which is the symptom in the question even though the effect re-ran and no race occurred. - 3b. StrictMode:
StrictModeis a wrapper component that, in development builds only, runs each effect's setup, cleanup, then setup again once on mount to expose missing cleanups. The log shows two fetches for id 7 in both components. With the cleanup, the first one is aborted and its result ignored, so the doubled setup is harmless. Without it (NoCleanup),abortedstays empty and both requests remain live, with nothing to stop the first from writing its result.
- 3a. Reset on change: once id 1 has loaded and the prop becomes 2, the
- Extra or unstable dependency:
[params], whereparams = { id }is created in the component body, is a new object every render, soObject.issays it changed on every render. Since the effect sets state, which renders again, which creates a new object, the effect re-runs in a loop. Step by step: render 1 buildsparamsobject A, the effect runs (run 1) and queuessetDatawith a fresh object, so state changes; render 2 builds object B, which is notObject.is-equal to A, so run 2 starts and again queues a fresh object; render 3 builds object C, and so on. The demo's own cap stopped it at 26 runs for a singleid; without the cap it continues until something breaks (rate limits, a frozen tab). With the primitive[id]as the dependency, the effect ran once. - Stale closure in a timer: an interval created in an effect with
[]readscountfrom the first render and logsempty:0after two clicks; with[count]the effect is re-created each time and logscount:2.
Removing the technique in the demo is cases 1 and 2: no dependency, or dependency without cleanup, and both assertions confirm the wrong output.
The fix, in order
- List every reactive value the effect reads (
[id]), and let thereact-hooks/exhaustive-depsrule check it (a rule fromeslint-plugin-react-hooks; ESLint is the tool that reports likely code mistakes while you type or in CI). React's page says a linter configured for React "will verify that every reactive value is correctly specified as a dependency." - Return a cleanup that ignores the previous request's result and aborts it (case 3).
- Reset the visible state when the input changes (the
setData('loading')line) so the old result is not shown under the new prop while waiting (case 3a). - Keep dependencies primitive or stable: pass
idrather than an object built in render, or build the object inside the effect. - Prefer a data-fetching library or framework loader over hand-written effects for anything beyond a prototype. React says that if you use a framework, its data fetching "will be a lot more efficient than writing Effects manually."
How to debug it
- Add a
console.log('effect', id)as the first line of the effect. No log after the prop change means a dependency problem; a log means the effect ran and the problem is the response. - Open the Network panel and pick a slower preset from its throttling dropdown, which delays responses so a race becomes visible. A missing request points to dependencies; two requests with the older one finishing last point to the race.
- In React DevTools, check that the prop really changed and which component holds the state.
- Log
idinside the.thenand compare it with the current prop: a mismatch confirms a late response. - If you see two requests on mount only in development, that is the StrictMode check, not the bug.
Trade-offs and pitfalls
- Do not silence the lint rule to stop a re-fetch loop; fix the unstable value instead. A suppressed warning is how the stale-closure bug in case 5 reaches production.
- An
ignoreflag stops the stale write but not the request;AbortControlleralso stops the network work if the API supports it. Use both, or at least the flag. - Stable and identical-looking dependencies are different things: two
{ id: 5 }objects are not equal underObject.is. - This run covers one component per failure mode with hand-controlled responses; it shows the mechanism, not how often each bug appears in production.
Running the code
npm i react@18.3.1 react-dom@18.3.1 @testing-library/react@16.0.1 @testing-library/dom@10.4.0 jsdom@25.0.1
node --disable-warning=ExperimentalWarning effect-deps.mjs # prints the lines above and "all assertions passed"
Save the listing as effect-deps.mjs beside a package.json containing { "type": "module" }. Tested on node:22 (v22.23).
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