Lyft Staff Full-Stack Developer Interview Preparation Guide
Lyft's interview process for Staff-level Full-Stack Developers typically consists of a recruiter screening phase, followed by technical phone screens, and concluding with a comprehensive onsite loop. The process evaluates technical depth across full-stack domains (frontend, backend, infrastructure), system design expertise, architectural decision-making, leadership capability, and cultural alignment with Lyft's mission-driven engineering culture.
Interview Rounds
Recruiter Screening
What to Expect
Initial phone screen with a recruiter to assess background fit, career trajectory, and interest in the Staff-level Full-Stack Developer role at Lyft. The recruiter will confirm your experience level (12+ years for Staff), validate your full-stack capabilities, and discuss your salary expectations and availability. This is also your opportunity to ask clarifying questions about the team, role scope, and what success looks like at Lyft.
Tips & Advice
Prepare a 1-2 minute professional summary highlighting your 12+ years of experience across frontend, backend, and infrastructure. Mention 1-2 notable projects where you owned significant technical decisions or led a team component. Have 3-4 thoughtful questions ready about Lyft's engineering culture, the specific team you'd be joining, and current technical challenges. This round is largely about screening out misalignment early; focus on demonstrating genuine interest in Lyft's ridesharing challenges and your ability to operate at Staff level.
Focus Topics
Compensation and Logistics
Be prepared to discuss salary expectations, signing bonus if applicable, start date, and visa sponsorship needs if relevant. Have a realistic range for Staff-level positions at large tech companies.
Practice Interview
Study Questions
Interest in Lyft's Mission and Challenges
Demonstrate understanding of Lyft's platform (ridesharing, driver operations, real-time dispatch, payments) and articulate why you're interested in solving Lyft's specific technical challenges. Show you've researched the company.
Practice Interview
Study Questions
Career Overview and Full-Stack Experience
Articulate your 12+ years of professional experience, emphasizing breadth across frontend technologies (React, Vue, Angular), backend systems (Node.js, Java, Python, Go), databases (SQL, NoSQL), and infrastructure/DevOps. Highlight projects where you contributed meaningfully to all layers.
Practice Interview
Study Questions
Leadership and Cross-Functional Collaboration
Describe your experience leading or influencing technical initiatives, mentoring engineers, and collaborating with product, design, and infrastructure teams. Highlight decisions you've influenced around technology selection, architecture, or team processes.
Practice Interview
Study Questions
Technical Phone Screen - Frontend & Full-Stack Fundamentals
What to Expect
A 60-minute technical interview conducted over video/phone focusing on frontend development, JavaScript fundamentals, and full-stack architectural thinking. You'll typically be asked to code in a shared environment (e.g., CoderPad) to implement a feature or solve an algorithmic problem with a frontend component. Expect questions on event handling, closures, asynchronous patterns, state management, and how your solution would integrate with a backend API. This round assesses your hands-on coding ability and your understanding of how frontend and backend systems interact.
Tips & Advice
Brush up on core JavaScript concepts (closures, hoisting, prototypes, async/await, Promises). Be comfortable coding in plain JavaScript and a modern framework (React preferred given Lyft's stack). When solving problems, think out loud about how the frontend would fetch data from a backend API, handle errors, and manage state. Don't just focus on the algorithm—discuss the user experience, performance implications, and how you'd optimize. If asked to implement a component like infinite scroll, discuss both event-based and IntersectionObserver approaches. Pay attention to code structure and readability; divide your solution into logical parts as you code.
Focus Topics
Algorithm Problem Solving and Optimization
Ability to solve medium-difficulty algorithmic problems efficiently (arrays, strings, trees, graphs, dynamic programming). Focus on optimal solutions, time/space complexity analysis, and discussing tradeoffs.
Practice Interview
Study Questions
Code Quality and Communication
Writing clean, readable, well-structured code. Dividing solutions into logical parts. Explaining your thought process clearly and asking clarifying questions when requirements are ambiguous.
Practice Interview
Study Questions
React and Modern Frontend Architecture
Proficiency with React, including hooks, component lifecycle, state management (Redux, Context), and performance optimization. Understand the tradeoffs between controlled and uncontrolled components, and when to use different state management approaches.
Practice Interview
Study Questions
Core JavaScript Fundamentals
Deep knowledge of JavaScript core concepts including closures, event delegation, prototypes, async/await, Promises, and this binding. Understand how JavaScript handles asynchronous code and memory management.
Practice Interview
Study Questions
Frontend-Backend Integration Patterns
Understanding of how frontend applications communicate with backend APIs (REST, GraphQL), handle authentication, manage sessions, implement pagination, handle real-time data, and gracefully degrade under network failures.
Practice Interview
Study Questions
Technical Phone Screen - Backend & System Design Fundamentals
What to Expect
A 60-minute technical interview focusing on backend development, API design, database design, and system architecture. You may be asked to design a small system (e.g., a ride tracking feature, user notification system), discuss database schema design, or implement a backend service feature. Expect questions about scaling considerations, database indexing, caching strategies, and how your backend would support frontend and mobile clients. This round assesses your backend architecture thinking and your ability to make data modeling and infrastructure decisions.
Tips & Advice
Prepare for both coding and architectural discussions. Be comfortable designing APIs (REST endpoints, request/response structure). Discuss database choices (SQL vs. NoSQL) and schema design with normalization/denormalization tradeoffs. When designing systems, think about scale from the beginning—Lyft deals with millions of concurrent users. Ask clarifying questions about traffic patterns, latency requirements, and consistency needs. Discuss caching (Redis, memcached), message queues (Kafka), and monitoring. Be familiar with cloud services (AWS, GCP) relevant to your experience. Show that you understand the full pipeline from user action to database write to response.
Focus Topics
Cloud Infrastructure and DevOps Fundamentals
Basic proficiency with cloud providers (AWS, GCP, Azure), containerization (Docker), orchestration (Kubernetes), CI/CD pipelines, and deployment strategies. Understanding how applications run in production.
Practice Interview
Study Questions
System Architecture and Scalability
Thinking about how systems scale with user/data growth. Understanding of sharding, replication, load balancing, and geographic distribution. Ability to reason about bottlenecks and design for observability.
Practice Interview
Study Questions
Backend API Design and REST Principles
Ability to design clean, scalable REST APIs. Understanding of HTTP verbs, status codes, request/response format, pagination, filtering, and error handling. Ability to design API contracts that work well with frontend consumers.
Practice Interview
Study Questions
Database Design and SQL/NoSQL
Proficiency in relational database design (normalization, indexing, query optimization) and NoSQL databases (document stores, time-series DBs). Understanding schema design, tradeoffs between consistency and availability, and when to use each type.
Practice Interview
Study Questions
Caching, Message Queues, and Asynchronous Processing
Understanding of caching layers (Redis, memcached), message queues (Kafka, RabbitMQ), and asynchronous job processing. When to use each, consistency considerations, and how they enable system scalability.
Practice Interview
Study Questions
Onsite - Technical Interview: Frontend Deep Dive
What to Expect
A 60-90 minute in-depth technical interview at Lyft's office (or via video if remote onsite) with a senior frontend engineer. You'll work through a realistic frontend coding problem or feature implementation, likely involving state management, API integration, and performance optimization. The interviewer may ask you to build a component from scratch (e.g., an autocomplete, infinite scroll, or interactive map feature relevant to ridesharing). Expect follow-up questions about how you'd test the code, optimize performance, handle edge cases, and scale the solution. This evaluates your ability to ship quality frontend code and your understanding of the full development workflow.
Tips & Advice
Go deep on React and modern frontend tooling. Be prepared to discuss how you'd implement features with performance in mind (e.g., memoization, code splitting, lazy loading). Discuss testing strategies (unit, integration, e2e). Ask about the specific problem domain—Lyft's frontend includes real-time map updates, live driver tracking, and payment flows, all performance-critical. When coding, write production-ready code: handle errors, add comments for complex logic, and consider edge cases. Show that you can think beyond just 'does it work?' to 'how do we monitor it, test it, and maintain it?' Be ready to discuss architectural choices you've made in past projects.
Focus Topics
Accessibility and Cross-Browser Compatibility
Understanding of web accessibility (WCAG standards, semantic HTML, ARIA), keyboard navigation, screen reader support, and ensuring features work across browsers and devices.
Practice Interview
Study Questions
Testing Frontend Code
Ability to write unit tests, integration tests, and end-to-end tests for frontend code. Understanding of testing libraries (Jest, React Testing Library, Cypress), test organization, and testing best practices.
Practice Interview
Study Questions
API Integration and Data Fetching
Robust handling of data fetching (loading, error, success states), caching strategies on the client, real-time data handling (WebSockets, Server-Sent Events), handling stale data, and retry logic.
Practice Interview
Study Questions
State Management at Scale
Deep understanding of state management patterns (Redux, Context, MobX, or Zustand). Ability to design state structures that scale, handle complex async flows, and enable efficient updates without performance degradation.
Practice Interview
Study Questions
React Component Architecture and Hooks
Expert-level understanding of React component design, functional components with hooks (useState, useEffect, useContext, custom hooks), performance optimization with useMemo and useCallback, and when to reach for different patterns.
Practice Interview
Study Questions
Frontend Performance Optimization
Techniques for optimizing frontend performance: code splitting, lazy loading, image optimization, memoization, virtual scrolling, request deduplication. Understanding of browser metrics (Core Web Vitals, paint timing) and debugging tools.
Practice Interview
Study Questions
Onsite - Technical Interview: Backend & API Design
What to Expect
A 60-90 minute technical interview with a senior backend engineer focusing on backend system design, API development, and databases. You'll likely be asked to design a backend feature or system (e.g., a notification service, ride history API, or driver availability matching system). Expect to discuss database schema, API design, caching strategies, and how to scale the system. The interviewer will probe your thinking about consistency, latency, and operational concerns. This round validates your backend depth and your ability to make sound architectural decisions.
Tips & Advice
Approach system design problems methodically: clarify requirements and constraints, estimate scale, propose a high-level architecture, detail the data model, discuss tradeoffs, and identify bottlenecks. Draw diagrams if possible. Be conversational—the interviewer wants to see how you think through problems, not just hear the final answer. For Lyft-specific problems, consider real-time requirements (driver tracking), geographic distribution, and high availability. Discuss monitoring and alerting as part of your design. Show knowledge of Lyft's technology stack (they use Golang, Python, Node.js, and various databases). Be ready to discuss how you'd evolve the system as it scales.
Focus Topics
Monitoring, Logging, and Observability
Design systems with monitoring and observability in mind. Understanding of metrics, logging, tracing, alerting, and debugging distributed systems. How to design for operational visibility.
Practice Interview
Study Questions
Caching Strategies and Performance Optimization
Deep understanding of caching layers, cache invalidation strategies, cache-aside vs. write-through patterns, and when caching helps vs. when it introduces complexity. Performance profiling and optimization.
Practice Interview
Study Questions
Distributed Systems Concepts
Understanding of distributed systems fundamentals: eventual consistency vs. strong consistency, replication strategies, consensus algorithms, network partitions, and how these concepts apply to backend services.
Practice Interview
Study Questions
System Design and Architecture
Ability to design systems at scale, thinking about load balancing, service-oriented architecture, microservices tradeoffs, and distributed system challenges (CAP theorem, eventual consistency, consensus).
Practice Interview
Study Questions
Database Schema Design and Optimization
Expert database design including normalization strategies, denormalization for performance, indexing strategies, query optimization, and understanding read/write patterns to inform schema decisions.
Practice Interview
Study Questions
Message Queues and Asynchronous Processing
Design of asynchronous systems using message queues (Kafka, RabbitMQ), understanding of at-least-once vs. at-most-once delivery, idempotency, and distributed transaction patterns.
Practice Interview
Study Questions
Onsite - System Design Interview
What to Expect
A 60-90 minute focused system design interview with a senior architect or tech lead. You'll be presented with an open-ended design problem related to Lyft's domain (e.g., designing the real-time ride matching system, geolocation service, or payment processing at scale). The emphasis is on architectural thinking, tradeoffs, and scaling. You're expected to ask clarifying questions, estimate requirements, design the system end-to-end (frontend, backend, infrastructure), and discuss how you'd evolve it. The interviewer will probe your decisions with follow-up questions, potentially asking you to redesign if requirements change.
Tips & Advice
Master the system design process: (1) Clarify requirements and constraints (users, QPS, latency targets, consistency needs), (2) Estimate scale (servers, storage, bandwidth), (3) High-level architecture (identify components, databases, caches, queues), (4) Detail and discuss tradeoffs, (5) Discuss failover and recovery. For Lyft-specific problems, think about real-time requirements, geographic distribution, and the interplay between drivers and riders. Draw architecture diagrams. Be prepared to defend your choices and explain tradeoffs. If the interviewer asks you to handle a new constraint, adapt gracefully and explain how the system changes. Show that you understand not just 'what' but 'why'—why this database, why this message queue, why this architecture?
Focus Topics
API and Data Contract Design
Designing APIs that can evolve without breaking clients. Understanding of versioning strategies, backward compatibility, and designing data contracts between services.
Practice Interview
Study Questions
Database Selection and Hybrid Approaches
Rationale for choosing specific databases (SQL, NoSQL, time-series, search) based on access patterns. When to use multiple data stores in a single system and how to manage consistency.
Practice Interview
Study Questions
Real-Time and Geo-Distributed Systems
Designing systems with real-time constraints (low latency requirements) and geographic distribution (serving users across regions). Understanding of CDNs, regional databases, and consistency challenges in distributed systems.
Practice Interview
Study Questions
Fault Tolerance and Disaster Recovery
Designing systems that degrade gracefully under failures. Understanding of failover, backup strategies, data replication, and recovery time objectives (RTO) and recovery point objectives (RPO).
Practice Interview
Study Questions
Large-Scale System Architecture
Designing systems that handle millions of users/requests. Understanding of service-oriented and microservices architectures, service boundaries, API contracts, and managing dependencies between services.
Practice Interview
Study Questions
Scaling Patterns and Bottleneck Analysis
Techniques for scaling: sharding, replication, caching, CDNs, and load balancing. Identifying bottlenecks in a design and understanding how to address them as load grows.
Practice Interview
Study Questions
Onsite - Behavioral and Leadership Interview
What to Expect
A 60-minute behavioral interview with a manager, team lead, or senior engineer from Lyft, focusing on your experience, decision-making, leadership, collaboration, and fit with Lyft's culture. Expect questions like 'Tell me about your most interesting/challenging project,' 'Why Lyft?', 'Describe a time you had to influence others without authority,' 'How do you handle disagreement with teammates?', and 'Tell me about someone you mentored.' For Staff level, there's emphasis on influencing across teams, making architectural decisions, and developing others. The interviewer assesses whether you'll thrive in Lyft's collaborative, fast-paced environment.
Tips & Advice
Prepare 5-6 well-structured stories using the STAR method (Situation, Task, Action, Result) that demonstrate: (1) Technical leadership (leading a complex project end-to-end), (2) Influence without authority (getting buy-in for an architectural change), (3) Mentorship (helping another engineer grow), (4) Handling conflict (disagreeing respectfully and finding compromise), (5) Learning from failure, (6) Why Lyft matters to you. For each story, have metrics: 'This decision reduced latency by 40%' or 'Mentored 3 junior engineers, 2 promoted to mid-level.' Be specific about your role—use 'I' language for your contributions, 'we' for team achievements. Listen carefully to questions and answer directly. When asked 'Why Lyft?', reference specific aspects of the product, mission, or engineering culture, not generic reasons.
Focus Topics
Learning from Failure
A story of a significant failure (project that didn't launch, decision you'd reverse, or mistake you made) and what you learned. Emphasize accountability, action taken, and how you applied the lesson.
Practice Interview
Study Questions
Architectural Decision-Making and Influence
Stories of proposing or leading significant technical decisions (framework upgrades, database migrations, microservices splits, caching strategies). How you evaluated tradeoffs, got stakeholder buy-in, and led the implementation.
Practice Interview
Study Questions
End-to-End Project Leadership
Evidence of leading complex projects from conception to launch. Include: scoping requirements, technical design decisions, timeline management, handling blockers, cross-functional collaboration (product, design, ops), and shipping impact. Quantify results (latency, throughput, user impact).
Practice Interview
Study Questions
Alignment with Lyft's Mission and Culture
Clear articulation of why you want to work at Lyft specifically (not just any tech job). Reference the product, mission (improving urban mobility), or specific engineering challenges you're excited about. Show you've researched the company.
Practice Interview
Study Questions
Mentorship and Developing Others
Specific examples of mentoring junior or mid-level engineers. How you helped them grow (skills, confidence, career growth). Mention promotions or engineers who moved to new roles because of guidance you provided.
Practice Interview
Study Questions
Navigating Disagreement and Collaboration
Examples of respectfully disagreeing with managers, teammates, or peers, and how you found common ground. Show you can advocate for your position while remaining collaborative and learning when others have valid points.
Practice Interview
Study Questions
Frequently Asked Full-Stack Developer Interview Questions
Explain the CAP theorem: what do consistency, availability, and partition tolerance mean, and why can a distributed system only keep two of the three guarantees once a network partition happens? Give one real system that favors consistency (CP) and one that favors availability (AP), and explain what each gives up.
Sample Answer
Direct answer
CAP theorem says a distributed system cannot simultaneously guarantee all three of Consistency (every read returns the most recent write), Availability (every request receives a non-error response), and Partition tolerance (the system keeps working when some nodes cannot reach others). Because real networks drop packets and links fail, partition tolerance is not optional, so the actual choice during a partition is between consistency and availability. Systems like Google Cloud Spanner lean CP (some requests are blocked or rejected to stay correct); systems like Cassandra or Riak in their common tuning lean AP (every request gets an answer immediately, even a possibly stale one).
Structured elaboration
The three letters, precisely
- Consistency: every node that answers a read returns the same, most-recent value; there is no "which replica did you happen to hit" effect.
- Availability: every request that reaches a non-failed node gets a response, in bounded time, even if that response can't be proven up to date.
- Partition tolerance: the system continues operating even though some messages between nodes are lost or delayed.
Why "CA" is not a real option
A system that isn't partition tolerant is only correct as long as the network never drops a message, which no real, multi-node network guarantees. So "CA" only describes a single-node system, or a distributed system that hasn't yet been tested by a real partition; it is not a deployable choice for a genuinely distributed service.
CP vs AP
| Choice | Kept | Given up during a partition | Example system | Choose when |
|---|---|---|---|---|
| CP | Consistency, partition tolerance | Availability: some nodes refuse or delay requests rather than risk a wrong answer | Google Cloud Spanner, CockroachDB, a single-leader relational database | Correctness has a real cost of being wrong (money, regulatory records) |
| AP | Availability, partition tolerance | Strong consistency: a read may return stale or conflicting data that the application or client has to reconcile | Cassandra, Riak, DynamoDB in its default tuning | Uptime and low latency matter more than a few seconds of staleness |
PACELC, the extension worth naming
PACELC (if Partitioned, choose Availability or Consistency; Else, choose Latency or Consistency) points out that CAP only describes what happens during a partition. Even with no partition at all, a system still has to decide, on every write, how synchronously to replicate: wait for every replica to acknowledge (favors consistency, costs latency) or acknowledge after one replica and replicate the rest in the background (favors latency, costs consistency). An AP system's day-to-day behavior is really governed by its PACELC answer, not its CAP answer, since partitions are rare and normal operation is not.
Quorum tuning as the middle ground
Many stores let you tune how many replicas must acknowledge a write (W) and how many must respond to a read (R) out of N total replicas, sliding the same system between CP-like and AP-like behavior per operation rather than committing the whole deployment to one label. The mechanics of quorum protocols are their own topic; the point for this answer is that the CP/AP choice is a dial, not a single global switch.
Worked example
Take three replicas (N = 3) with a write quorum W = 2 and a read quorum R = 2, so that any write and any read are guaranteed to overlap on at least one replica:
W+R>N⇒2+2>3
Now a partition splits the three replicas into a group of 2 and a group of 1.
- The group of 2 can still assemble a quorum (2 out of the required 2), so it keeps serving reads and writes: consistent and available, on that side.
- The lone replica cannot assemble a quorum (1 is short of 2), so it must refuse requests rather than answer from possibly-stale local data: it sacrifices availability to avoid sacrificing consistency.
If the same store were instead configured for W = 1, R = 1 (an AP-leaning setting), both sides could keep accepting writes independently during the same partition, and the two sides would need to reconcile any conflicting writes once the partition heals; nothing here changes the network, only the dial.
Two alternate ways to see the same trade, folded in from adjacent scenarios: to a non-engineer stakeholder, the choice can be framed without the theorem's name at all: "during an outage, would you rather the app show a slightly out-of-date number and stay up, or freeze and show an error until we're sure the number is right?" That question is CAP in plain language. The same trade also shows up in machine learning infrastructure: an online feature store serving features to a live model at inference time typically leans AP (serve the last known values within a freshness window rather than block the request), while the offline pipeline that snapshots those same features for a training run typically leans CP (a training set built from a partially-written snapshot silently corrupts the model, so the pipeline waits for a consistent point-in-time snapshot instead).
Trade-offs & pitfalls
- Treating "pick 2 of 3" as one permanent, whole-system architecture decision, instead of a per-operation or even per-data-type decision a single platform can make differently in different places.
- Conflating CAP's "C" with the "C" in ACID (atomicity, consistency, isolation, durability): CAP consistency is about replicas agreeing with each other; ACID consistency is about the database enforcing its own integrity constraints. They answer different questions and can both be true or false independently.
- Forgetting PACELC: an AP system still owes an answer to "how synchronously do you replicate during normal operation," since that decision affects latency and staleness every day, not just during rare partitions.
- Citing "CA" as if it were a legitimate label for a real distributed deployment, rather than a description of a single-node system or an untested assumption.
You are given the following Java concurrency snippet. Identify the potential deadlock and explain why it can happen. Propose at least three different fixes or mitigations (code-level and architectural). Include how you would detect such a deadlock in production.
public class Account {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void transferTo(Account other, int amount) {
synchronized (lockA) {
// prepare
synchronized (other.lockB) {
// perform transfer
}
}
}
public void receiveFrom(Account other, int amount) {
synchronized (other.lockA) {
synchronized (lockB) {
// apply receive
}
}
}
}
Sample Answer
Tracing the actual lock-acquisition order in this exact snippet shows it does not deadlock as written, which is worth establishing precisely before proposing fixes: transferTo always acquires lockA (its own) first, then lockB (the other account's) second. receiveFrom always acquires lockA (the other account's) first, then lockB (its own) second. Every code path acquires an "A-role" lock before a "B-role" lock, and never the reverse, for any combination of this/other. That is a consistent two-level lock hierarchy (all A-locks before all B-locks), and a consistent hierarchy like that is deadlock-free by construction: a circular wait needs some thread to hold a lock another thread needs while itself waiting on a lock the first thread holds, and no two concurrent calls here ever end up wanting the same pair of locks in reversed order. This was confirmed by simulating the exact lock-acquisition structure (each account with two independent lock objects, in Python with threading.Lock standing in for synchronized, since Java itself wasn't available in this environment) and running every pairwise combination of transferTo/receiveFrom concurrently, plus a 2000-iteration stress run in both directions with zero delay: all completed without blocking.
public class Account {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void transferTo(Account other, int amount) {
synchronized (lockA) { synchronized (other.lockB) { /* transfer */ } }
}
public void receiveFrom(Account other, int amount) {
synchronized (other.lockA) { synchronized (lockB) { /* apply */ } }
}
}
The real bug pattern this code is easy to confuse with
The classic version of this deadlock happens when there is a single lock per account, acquired in reversed order across two call directions, for example:
public class Account {
private final Object lock = new Object();
public void transferTo(Account other, int amount) {
synchronized (lock) {
synchronized (other.lock) { /* transfer */ }
}
}
}
If X.transferTo(Y) and Y.transferTo(X) run concurrently here, thread 1 holds X.lock waiting for Y.lock, while thread 2 holds Y.lock waiting for X.lock: a genuine circular wait, and neither thread can proceed. This is the scenario the two-lock version above superficially resembles but, because it splits the single lock into a distinct "A" and "B" per account and always acquires A before B on every path, it does not actually reproduce it. Any change that collapses lockA/lockB back into one lock, or that adds a future method acquiring lockB before lockA, would reintroduce exactly this hazard, so the design is fragile even though this specific snippet is safe today.
Fixes and mitigations (apply to the single-lock pattern, or as defense-in-depth here)
- Total lock ordering independent of call direction: always acquire locks in a fixed global order (e.g., by a stable account ID, lower ID first) regardless of which account is "self" versus "other." This is the standard, architecturally simplest fix, and it's deadlock-free by construction the same way the two-role split above happens to be, but without depending on every future method preserving an A-before-B convention by convention alone.
tryLockwith a timeout instead of blockingsynchronized, retrying (with backoff/jitter) on failure to acquire, so a would-be deadlock becomes a bounded retry instead of a permanent stall.- A single coarser lock (one lock per pair of accounts, or a global transfer lock), trading concurrency for simplicity when transfer volume doesn't need fine-grained per-account locking.
Detecting it in production
JVM thread dumps (jstack) explicitly report a "Found one Java-level deadlock" section naming the exact threads and locks involved when a genuine circular wait exists; a monitoring alert on threads stuck in BLOCKED state for longer than a threshold is a cheap early warning before a full stall is noticed by users. For code shaped like this two-lock version, absence of that jstack section (paired with the ordering analysis above) is itself useful confirmation that a reported "hang" has a different cause, such as an unrelated slow call held inside one of the synchronized blocks rather than a lock-ordering deadlock.
Trade-offs and pitfalls
Fix 1 (total ordering) is preferred because it's deadlock-free by construction and doesn't add retry latency; fix 2 (tryLock+timeout) is a reasonable fallback when a stable total order is hard to establish across the whole codebase, but it adds complexity and needs careful backoff to avoid livelock (both sides retrying in lockstep and failing forever). The broader pitfall this snippet illustrates: a locking scheme that happens to avoid deadlock today because of an incidental structural property (two distinct lock roles always acquired in the same order) is not the same thing as a scheme that is deadlock-free by design, and should not be trusted to stay safe as the code evolves.
JSON.stringify returns an empty object for a Map or Set. Explain why, and design a way to serialise and restore data that contains them, including nested ones.
Sample Answer
Direct answer
JSON.stringify only visits an object's own enumerable string-keyed properties, and a Map or Set has none: its entries live in an internal slot (hidden storage inside the object) that property enumeration cannot see, so both serialize as {} (MDN states this). To round-trip them, convert them to an ordinary JSON shape the other side can recognise: a tagged object such as {"$type":"Map","entries":[[key, value], ...]} produced by a replacer function passed to JSON.stringify, and rebuilt by a reviver function passed to JSON.parse. Nested values work because both callbacks are called for every value in the tree.
Terms: a replacer is a function (key, value) that JSON.stringify calls for each value and whose return value is what gets serialized (inside it, this is the object holding the key). A reviver is the matching function for JSON.parse, called after parsing each value, deepest values first. A tag is a marker field (here $type) that tells the reviver what to rebuild.
Design: encode, decode, and the traps
- Entries, not objects. A
Mapbecomes an array of[key, value]pairs, not a plain object, because Map keys may be non-strings (numbers, objects) and an object would stringify them all. ASetbecomes an array of values. - Nesting for free. After the replacer returns the tagged object,
JSON.stringifyvisits its contents, so aMapinside aMapinside aSetis tagged at each level. The reviver runs bottom-up, so by the time it sees an outer{"$type":"Map"}, the inner ones are already rebuilt. - Values JSON cannot hold.
BigIntthrows aTypeErrorinJSON.stringify(MDN), even if a replacer returns one, so it is tagged as a string of digits. ADatehas atoJSONmethod that runs before the replacer, so the replacer receives a string; read the original throughthis[key]. - Symbol keys. MDN: symbol-keyed properties are ignored, even with a replacer, so they cannot be handled key by key. The scheme detects a plain object that has enumerable symbol keys and re-emits it as a tagged
Objwith its string entries and its symbol entries as[name, value]pairs. Only registered symbols (created withSymbol.for(name)) can be restored with the same identity, becauseSymbol.keyForrecovers the name andSymbol.forreturns the same symbol. A uniqueSymbol("local")cannot come back, so the encoder throws rather than silently dropping data. - Tag collisions. If real user data already has a
$typekey, a naive reviver would misread it. The encoder therefore also wraps any plain object that owns a$typekey into the taggedObjform (entries as pairs, so no raw$typekey remains in the output), and the reviver leaves unknown tags alone. - Trust. The decoder only builds built-in types from fixed tags, never evaluates code or looks up constructors by name from the input.
Working implementation
import assert from "node:assert/strict";
const isPlain = (v) =>
v !== null && typeof v === "object" && Object.getPrototypeOf(v) === Object.prototype;
// replacer: called for every value, depth first, with `this` = the holder object.
function replacer(key, value) {
const raw = this[key]; // `value` is already toJSON()-ed; Date, for example, is a string by now
if (raw instanceof Date) return { $type: "Date", iso: raw.toISOString() };
if (value instanceof Map) return { $type: "Map", entries: [...value] };
if (value instanceof Set) return { $type: "Set", values: [...value] };
if (typeof value === "bigint") return { $type: "BigInt", digits: value.toString() };
if (isPlain(value)) {
const symbols = Object.getOwnPropertySymbols(value).filter((s) =>
Object.prototype.propertyIsEnumerable.call(value, s));
const hasTagKey = Object.hasOwn(value, "$type");
if (symbols.length || hasTagKey) {
return {
$type: "Obj",
entries: Object.entries(value),
symbols: symbols.map((s) => {
const name = Symbol.keyFor(s);
if (name === undefined) throw new TypeError("cannot serialise non-registered symbol key " + String(s));
return [name, value[s]];
}),
};
}
}
return value;
}
// reviver: called bottom-up, so nested tagged values are already rebuilt.
function reviver(key, value) {
if (value === null || typeof value !== "object" || typeof value.$type !== "string") return value;
switch (value.$type) {
case "Date": return new Date(value.iso);
case "Map": return new Map(value.entries);
case "Set": return new Set(value.values);
case "BigInt": return BigInt(value.digits);
case "Obj": {
const obj = Object.fromEntries(value.entries);
for (const [name, v] of value.symbols) obj[Symbol.for(name)] = v;
return obj;
}
default: return value;
}
}
export const encode = (data) => JSON.stringify(data, replacer);
export const decode = (text) => JSON.parse(text, reviver);
// --- demo ---
console.log("plain JSON of a Map:", JSON.stringify(new Map([["a", 1]])), "| Set:", JSON.stringify(new Set([1, 2])));
const id = Symbol.for("app.id");
const original = {
user: "ada",
roles: new Set(["admin", "dev"]),
scores: new Map([
["math", new Map([["q1", 90], ["q2", 85]])], // Map nested in a Map
[{ week: 3 }, new Set([1n, 2n])], // object key, Set of BigInt
]),
seen: new Date("2026-01-02T03:04:05.000Z"),
meta: { [id]: 42, $type: "user-supplied value" }, // symbol key and a key that collides with our tag
note: { $type: "Map", entries: [["fake", 1]] }, // user data that merely looks like our Map tag
};
const text = encode(original);
console.log(text);
const restored = decode(text);
assert.deepStrictEqual(restored, original);
console.log("round trip equal:", true);
console.log("restored symbol key:", restored.meta[id], "| nested Map:", restored.scores.get("math").get("q2"));
try {
encode({ [Symbol("local")]: 1 });
} catch (e) {
console.log(e.name + ": " + e.message);
}
Run in node:22, it prints:
plain JSON of a Map: {} | Set: {}
{"user":"ada","roles":{"$type":"Set","values":["admin","dev"]},"scores":{"$type":"Map","entries":[["math",{"$type":"Map","entries":[["q1",90],["q2",85]]}],[{"week":3},{"$type":"Set","values":[{"$type":"BigInt","digits":"1"},{"$type":"BigInt","digits":"2"}]}]]},"seen":{"$type":"Date","iso":"2026-01-02T03:04:05.000Z"},"meta":{"$type":"Obj","entries":[["$type","user-supplied value"]],"symbols":[["app.id",42]]},"note":{"$type":"Obj","entries":[["$type","Map"],["entries",[["fake",1]]]],"symbols":[]}}
round trip equal: true
restored symbol key: 42 | nested Map: 85
TypeError: cannot serialise non-registered symbol key Symbol(local)
Reading the long encoded line from left to right:
"user":"ada"is an ordinary string and passes through the replacer unchanged."roles":{"$type":"Set","values":["admin","dev"]}is theSet, replaced by a tagged object holding its values as an array."scores":{"$type":"Map","entries":[...]}is the outerMap. Itsentriesarray holds two[key, value]pairs. The first pair is["math", {"$type":"Map","entries":[["q1",90],["q2",85]]}]: the innerMapis tagged again because the replacer is called on it too.- The second pair is
[{"week":3},{"$type":"Set","values":[...]}]. Its key{"week":3}is an ordinary object written out as JSON, and its value is aSetwhose twoBigIntmembers (1nand2nare BigInt literals, whole numbers of any size) each became{"$type":"BigInt","digits":"1"}and{"$type":"BigInt","digits":"2"}, because plain JSON cannot hold a BigInt. "seen":{"$type":"Date","iso":"2026-01-02T03:04:05.000Z"}is theDate, taken fromthis[key]before itstoJSONstring replaced it."meta":{"$type":"Obj","entries":[["$type","user-supplied value"]],"symbols":[["app.id",42]]}is the plain object that had two special features. Its own$typekey moved intoentries, so the only raw$typein that object is the tag the encoder wrote, and its symbol keySymbol.for("app.id")was written as the pair["app.id", 42]undersymbols."note":{"$type":"Obj","entries":[["$type","Map"],["entries",[["fake",1]]]],"symbols":[]}is user data that only looks like aMaptag and has no symbol keys. The$typerule alone wrapped it, so the reviver restores it as the plain object it was instead of building aMap.
The assertion assert.deepStrictEqual(restored, original) compares the Maps, Sets, Date, BigInt values, symbol-keyed property and the look-alike note object deeply and passes. Replacing the $type test with const hasTagKey = false; makes this assertion throw an AssertionError, because note then comes back as a real Map. The object key { week: 3 } is round-tripped as an equal object, not the same object: JSON cannot preserve identity (two keys that were the same reference come back as two copies), so Maps keyed by object identity do not survive in the strict sense.
Alternatives and trade-offs
| Option | Use when | Limit |
|---|---|---|
| Tagged replacer/reviver (above) | Text must be JSON: HTTP bodies, localStorage, logs, files | Every consumer must know the tags; cycles still throw |
Convert at the edge to plain arrays/objects ([...map], Object.fromEntries) | Format is a public API contract | Rebuild by hand at each call site; no automatic nesting |
structuredClone / postMessage | Copy in memory or between workers and tabs | Not text, so it cannot be stored in JSON or sent over HTTP |
Define toJSON on your own classes | Types you own | Does not help for built-in Map and Set unless you subclass or patch prototypes (avoid patching) |
Pitfalls: a Map with duplicate-looking keys (1 and "1") stays distinct as entries but would collide in an object; large payloads are walked twice (once by the replacer, once by JSON.stringify); version the format (a "v" field) so tags can change safely; a reviver returning undefined deletes the property.
Running the code
Save the listing as codec.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 codec.mjs'
Set two SMART goals with someone you're mentoring who needs to grow in a specific area of their job. Walk through how you picked those goals and how you'd know they'd been met.
Sample Answer
Direct answer
Two well-chosen SMART goals for a mentee should target different dimensions, not two flavors of the same gap, typically one concrete skill or output gap and one behavioral or collaboration gap, each tied to real upcoming work (not an abstract exercise) with a defined timeframe and a way to verify progress that isn't just your own impression.
Structured elaboration
Picking the goals
- Start from an actual observed gap, not a generic template. Watch the person's real work for a pattern (recurring rework in reviews, difficulty scoping ambiguous tasks, avoiding certain kinds of conversations) rather than picking goals off a checklist.
- Pick goals from different dimensions on purpose. Two goals that are both "write better code" don't cover as much ground as one technical goal and one collaboration or communication goal; below-the-bar performance and stalled growth are rarely single-dimensional.
- Anchor each goal to real, upcoming work rather than an artificial exercise, so achieving it has actual value beyond the goal itself.
Making them SMART without making them hollow
- Specific: named against a real, current gap, not a generic aspiration ("get better at code review" is weak; "flag the two or three highest-risk issues in a review instead of commenting on every minor style choice" is usable).
- Measurable: defined by evidence you can point to later, not a feeling. This doesn't require an invented precision metric; "the last three reviews they gave focused on real risk rather than style nits" is legitimate evidence.
- Achievable: a real stretch, not guaranteed, but genuinely possible in the timeframe given their current level.
- Relevant: tied to what actually matters for their next step, not an arbitrary skill.
- Time-bound: a defined window, short enough to check in on meaningfully, long enough for real practice to happen.
Verifying they were met
Verification should come from something observable in the work itself, ideally corroborated by someone other than just you (a peer's comment, a second reviewer's read), not solely your own subjective sense that things feel better.
Worked example
Situation
A mentee was technically solid but had two recurring gaps: their code reviews tended to focus on minor style points while missing the real risk in a change, and they rarely spoke up in group design discussions even when they clearly had a relevant opinion afterward.
The two goals
- Review focus: over the next 6 weeks, shift their code review comments toward flagging genuine risk (correctness, edge cases, design concerns) rather than style, verified by a second reviewer independently agreeing their flagged issues were the real risk areas in at least the majority of reviews they gave in that window.
- Speaking up in design discussions: over the next 8 weeks, raise at least one substantive point live in a design discussion, rather than only afterward privately, verified simply by whether it happened and by a peer noticing the shift unprompted.
Why these two, not two code-quality goals
Picking a technical goal and a behavioral goal together addressed two independent gaps at once, rather than doubling down on the dimension that was already their relative strength.
Result
Both goals gave something concrete to check in on during regular 1:1s, and both had a verification method that didn't rely purely on my own impression, which mattered for making the conversation feel objective rather than a subjective judgment.
Trade-offs & pitfalls
- Goals that sound measurable but aren't actually verifiable. "Be more proactive" dressed up with a number attached is still not a real SMART goal if there's no real way to check it.
- Two goals in the same dimension. Picking two technical goals, or two soft-skill goals, leaves a real gap uncovered and wastes the opportunity a second goal represents.
- Goals set without the mentee's buy-in. A goal the mentee didn't help shape, or doesn't actually agree reflects a real gap, is much less likely to stick, even if it's technically well-formed.
- No connection to real work. An artificial exercise goal ("complete this course") is weaker evidence of growth than a goal embedded in work they were doing anyway.
Why is SELECT * considered a performance anti-pattern for production dashboards, ETL jobs, and large queries? Rewrite a wide, unfiltered SELECT * query to be production-safe and explain each dimension of the improvement (I/O, network transfer, index-only-scan eligibility).
Sample Answer
Direct answer. SELECT * pulls every column regardless of what the query actually needs, which increases network transfer, defeats the possibility of an index-only scan (since the index almost never contains every column), and silently breaks if the table's column set changes; rewrite it to name only the columns the caller actually uses.
Structured elaboration. Three distinct costs stack up. First, I/O and network: every extra column is extra bytes read from storage and sent over the wire, even for columns the caller immediately discards, which matters most for wide tables or ones with large text/JSON columns. Second, index eligibility: an index-only scan requires every needed column to be present in the index; asking for every column in the table makes that essentially impossible for any index narrower than the full row, forcing a heap visit that a narrower SELECT might have avoided. Third, fragility: if the table gains a column later, every SELECT * consumer starts receiving it whether or not it's ready to, which has broken more than one downstream integration in ways that are hard to trace back to the schema change that caused it.
Worked example. For transactions(transaction_id, user_id, amount, currency, created_at, status, metadata jsonb), a dashboard that only needs the four most recent completed transactions' amount and date has no business fetching the metadata JSONB column at all:
-- anti-pattern: pulls every column, including a large JSONB payload
SELECT * FROM transactions
WHERE status = 'completed'
ORDER BY created_at DESC
LIMIT 100;
-- production-safe: only the columns the caller actually uses
SELECT transaction_id, amount, created_at
FROM transactions
WHERE status = 'completed'
ORDER BY created_at DESC
LIMIT 100;
The rewrite reduces network payload substantially (dropping metadata, currency, status, and user_id from the wire format) and makes it possible, if status and created_at were part of a covering index that also included transaction_id and amount, for the query to be served entirely from that index.
Trade-offs and pitfalls. Naming columns explicitly is marginally more code to write and to keep in sync as requirements change, which is the entire reason SELECT * remains tempting; treat that maintenance cost as strictly smaller than the recurring, compounding cost of over-fetching on every single execution of a query that runs often.
Complexity
The change doesn't alter the query's algorithmic shape; it changes the constant factor on I/O and network transfer per row, and can change whether an index-only path is even available at all.
Edge cases
A table with a genuinely small number of columns, all of which the caller uses anyway, gets little practical benefit from this rewrite; the cost matters most on wide tables or ones with large variable-length columns like JSON or text blobs.
Design the REST API for a data-enrichment microservice that multiple downstream teams will call, needing to sustain 1,000 requests per second at a P95 latency target of 200 milliseconds. Specify the endpoints and request/response contract, your idempotency approach for retried writes, your error model, and how you version the contract as the enriched schema evolves. Sketch, at a high level, how you would validate the design can actually sustain that load.
Sample Answer
Direct answer. Design the API around a small, stable resource shape (an enrichment request/result pair), make writes idempotent from day one (idempotent meaning a retried request produces the exact same end result as the original one, so a client's automatic retry after a timeout never re-runs the enrichment or double-counts a record) given the explicit retry-heavy, multi-consumer context, and version the response schema separately from the endpoint path so downstream teams can adopt schema changes on their own timeline rather than a coordinated flag day.
Endpoints and contract.
POST /enrichmentsaccepts a batch of input records (bounded batch size, say up to 500 per call, to keep P95 latency achievable — P95 latency is the response time under which 95% of requests finish; the slowest 5% are allowed to take longer, which is a stricter bar than an average, since an average can look fine even while a meaningful tail of requests runs long) and anIdempotency-Keyheader; returns 202 Accepted with aLocationpointing at a status resource, since enrichment at this volume is realistically an asynchronous operation even if individual small batches complete quickly.GET /enrichments/{batchId}returns the batch's status and, once complete, the enriched results, or partial results with per-item status if some items in the batch succeeded and others failed.- The RESPONSE schema carries an explicit
schema_versionfield, separate from any URL versioning, so a downstream team's parser can check it and know exactly which fields to expect, without every consumer needing to move in lockstep with every schema change.
Idempotency approach. The Idempotency-Key on the batch submission covers the whole batch as a unit, the same design as a bulk-write endpoint: a retried submission with the same key replays the original batch's result rather than re-running the enrichment (which may call expensive downstream data sources) or double-counting records in whatever aggregate the enrichment service maintains. Given multiple downstream teams calling this service, each team's own key generation needs to be genuinely unique per LOGICAL batch, not accidentally shared across teams; namespacing the key by caller (or requiring the caller's own service identity as part of the key) prevents one team's retries from ever colliding with another's.
Error model. A per-item error structure (not just a single batch-level error) is essential here, since a batch of 500 enrichment requests failing entirely because ONE input record was malformed would be a poor contract for downstream teams; each item's result reports its own success/failure independently, with a batch-level summary count.
Schema versioning as the volume grows. Since "downstream teams" implies multiple independent consumers evolving at different speeds, prefer additive-only changes to the response schema (new optional fields) over breaking ones whenever possible, and reserve an actual version bump for the rare case an existing field's meaning or type must change; this keeps most schema evolution invisible to consumers who do not care about the new field, rather than forcing every consumer to move in lockstep.
Validating the design can sustain 1,000 requests per second at P95 200ms. At a high level: load-test the actual enrichment path (not just the API's own request handling) against realistic downstream-dependency latency, since the enrichment logic calling external or internal data sources is very likely the true bottleneck, not the HTTP layer itself; confirm the idempotency-key storage lookup (a single indexed read per batch) stays cheap under this load, since that lookup sits on every request's critical path; and measure P95, not average latency, specifically, since an average can look fine while a meaningful tail of requests blows past the 200ms target.
Trade-offs and pitfalls. The most common mistake at this specific intersection (idempotency plus versioning plus multiple independent consumers) is designing the idempotency key and the schema-versioning strategy in isolation from each other; a schema change that alters what a stored (already-completed) idempotency result even MEANS can make an old cached response invalid for a client expecting the new schema, which needs an explicit policy (does an idempotency-key replay always return the schema version it was originally created under, or the current one?) rather than being left to accident.
Tell me about a time you proactively removed a blocker, technical, process, or people, that was standing between your delivery and shipping. How did you spot it, what concrete steps did you take (technical fixes, workarounds, tooling, or just coordinating with the right people), whether and when you looped in others or escalated, and what measurably changed in your delivery timeline or team's velocity as a result.
Sample Answer
Direct answer
The strongest version of this story shows a blocker noticed before it became someone else's emergency, an action matched to how much authority you actually had over what you changed (fixing it directly if it was fully yours, coordinating if it touched shared infrastructure, escalating only if it was genuinely outside your reach), and a real, specific before-and-after change in the delivery timeline, not a general sense that things felt smoother.
Structured elaboration
- Spotting it: point to a concrete detection signal, not a vague feeling, for example a recurring delay pattern that showed up in the same way across the last several delivery cycles, or a piece of infrastructure that everyone quietly routes around because nobody actually owns fixing it.
- Matching the action to your authority: a fix fully within your own control (your own code, your own team's process) can be built and shipped directly. Something that touches shared infrastructure or another team's system calls for coordinating with the right people first, rather than unilaterally changing something you do not own.
- When to loop others in: loop in the owner of anything outside your own immediate scope before changing it, not after, and loop in your manager or affected stakeholders whenever the fix will visibly shift timeline expectations, even a positive shift, so nobody is caught by surprise either way.
- Measuring the result: close with an honest, specific before-and-after comparison of the exact thing that changed, a step in a process, a recurring wait time, a manual task that used to eat calendar time, described plainly rather than with inflated precision.
Worked example
Across the last four release cycles, a manual, ticket-based approval step to provision a test environment consistently cost the team roughly half a day of waiting each time, even though the criteria for approval had become entirely mechanical, the same three checks every time, with no real judgment call left in it. The fix: a small self-service script that ran those same three checks automatically and auto-approved when they passed, falling back to the manual ticket only when a check actually failed. Because the change touched a provisioning system owned by another team, its lead was looped in before anything shipped, rather than deploying a workaround directly into a system this person did not own, and sign-off came quickly since the change only automated a decision that was already mechanical, not a judgment call being taken away from anyone. Result: the environment-provisioning wait dropped from roughly half a day to about 15 minutes for the common case, recovering close to half a day of calendar time per release cycle, and across the next four releases where the team used it, this removed what had been the single largest scheduled delay in the release checklist.
Trade-offs and pitfalls
Unilaterally changing shared infrastructure without looping in its owner, even with good intentions, erodes trust and risks breaking something not fully understood. Choosing a blocker that is satisfying to fix but not actually on the delivery-critical path does not move the timeline at all, no matter how good the fix feels. And closing with a vague claim that things felt smoother afterward, instead of naming the one specific thing that got measurably faster, is the difference between a story that demonstrates ownership and one that only asserts it.
A news site should show fresh content when the user is online but let people reread articles they already visited when offline. Which service worker caching approach do you pick for the page shell, article bodies and fast-changing parts like comments, and why?
Sample Answer
Direct answer
Pick a different strategy for each of the three kinds of content, because they change at different speeds and are worth different things offline:
| Content | Strategy | Why |
|---|---|---|
| Page shell (HTML frame, CSS, JavaScript) | Precache at install, then cache first | It only changes when you deploy, so the right version is known in advance and can be served instantly, online or off |
| Article bodies | Network first, falling back to the cache (store every successful response) | Online readers get the current text; offline readers get the last copy they saw |
| Comments | Network only (with a clear offline message) | They change by the minute; a stale comment count shown as current is worse than a gap |
A service worker is a script the browser runs in the background between the page and the network. It has a lifecycle with three stages that the listing below uses: install (the browser has just downloaded a new worker script; this is where files are downloaded into the cache ahead of time, which is called precaching), activate (the new worker takes over from the old one; this is where old caches are deleted) and then fetch (the worker answers requests). e.waitUntil(promise) tells the browser not to end that stage until the promise settles, and skipWaiting() and clients.claim() (explained under the cache lifecycle section) make a new worker start controlling open pages immediately instead of after every tab closes. A page the worker controls is called a controlled page. Its fetch event fires for every request from pages it controls, and the code decides whether to answer from the network, from the Cache Storage (a browser store of request-and-response pairs that scripts control), or a mix. The patterns come from web.dev's offline cookbook ("Cache, falling back to network", "Network falling back to cache", "Network only" and "Stale-while-revalidate"); "cache first" and "network first" are the shorter names that tooling such as Workbox uses for the first two.
Why each choice, with the cookbook's own wording
- Shell: precache plus cache first. The cookbook says to precache, that is to fetch "the things that would make your site entirely unfunctional if they failed to be fetched" during install, for "CSS, images, fonts, JS, templates, or anything else you'd consider static to that version of your site". Cache-first is right here because the cache is filled with exactly one deploy's files. Every article URL gets the same precached
shell.htmlfor navigation requests (the request a browser makes when the address bar or a link loads a whole new page, as opposed to a script or image request made by a page already open), and the JavaScript inside it fetches the article data. - Articles: network first. The cookbook recommends "network falling back to cache" as "a quick-fix for resources that update frequently, outside of the 'version' of the site". For a news site that matches the brief: fresh text when online, and a reread when offline. Its stated downside is real: "If the user has an intermittent or slow connection they'll have to wait for the network to fail", so the code below races the fetch against a 3,000 ms timeout and falls back to the cached copy when the timer wins.
Promise.race([a, b])settles as soon as the first ofaorbsettles: hereais the network request andbis a promise that rejects after 3,000 ms, so a request that has not answered within 3 seconds is treated as failed and thecatchbranch returns the cached copy. Pick the timeout from your slowest acceptable page load, not from this example. If freshness mattered less than speed, stale-while-revalidate ("If there's a cached version available, use it, but fetch an update for next time") is the alternative to switch to, at the cost that every reader sees the previous version on a first reload after an edit. - Comments: network only. The cookbook recommends it for "things that have no offline equivalent". A cached comment thread has no value as a record, and showing yesterday's count next to today's article misleads. The worker has no handler for them, so the request goes straight to the network, and the page replaces a failed request with "Comments unavailable offline".
The implementation, run end to end
The demo has a small server, a service worker, and a page that shows the article text and comment count. The server can bump a version number (an article edit plus new comments), can "go down" by dropping every connection, which is how this test makes the network disappear, can accept article requests and never answer them (a stalled network), always answers 500 for article 6, and can serve a changed sw.js so that the browser installs a new worker. Playwright drives Chromium through eight checks (the listing is explained step by step after the printed output): fresh content while online, a visited article offline, comments offline, an unvisited article offline, a stalled network answered from the cache after the 3,000 ms timeout, a cache limited to the 3 newest articles, a 500 response kept out of the cache, and old caches deleted when a new worker version activates.
// sw-demo.mjs
import http from 'node:http';
import { chromium } from 'playwright';
const BREAK = process.env.BREAK === 'cachefirst'; // BREAK=cachefirst serves article bodies cache-first
const MAX_ARTICLES = 3; // article cache keeps the 3 most recently stored bodies
const SW = `
const SHELL = 'shell-v1', ARTICLES = 'articles-v1', MAX = ${MAX_ARTICLES};
const SHELL_FILES = ['/shell.html', '/app.js', '/shell.css'];
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(SHELL).then((c) => c.addAll(SHELL_FILES)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (e) => { // drop caches from older deploys, take control of open pages
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SHELL && k !== ARTICLES).map((k) => caches.delete(k))))
.then(() => self.clients.claim()));
});
async function networkFirst(request) {
const cache = await caches.open(ARTICLES);
try {
const fresh = await Promise.race([fetch(request), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 3000))]);
if (fresh.ok) {
await cache.put(request, fresh.clone());
const keys = await cache.keys(); // oldest first; trim to the newest MAX
await Promise.all(keys.slice(0, Math.max(0, keys.length - MAX)).map((k) => cache.delete(k)));
}
return fresh;
} catch {
return (await cache.match(request)) || new Response('{"offlineMiss":true}', { status: 503, headers: { 'content-type': 'application/json' } });
}
}
async function cacheFirst(request) {
const cache = await caches.open(ARTICLES);
return (await cache.match(request)) || networkFirst(request);
}
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
if (e.request.mode === 'navigate') { // any article URL gets the same precached page shell
e.respondWith(caches.match('/shell.html').then((r) => r || fetch(e.request))); return;
}
if (SHELL_FILES.includes(url.pathname)) { e.respondWith(caches.match(e.request).then((r) => r || fetch(e.request))); return; }
if (url.pathname.startsWith('/api/article/')) { e.respondWith(${BREAK ? 'cacheFirst' : 'networkFirst'}(e.request)); return; }
// /api/comments/* : no handler, so the request goes to the network and fails when offline
});`;
const APP = `
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');
const id = location.pathname.split('/').pop();
(async () => {
const a = await fetch('/api/article/' + id);
document.getElementById('body').textContent = a.ok ? (await a.json()).text : 'This article is not saved for offline reading.';
document.getElementById('body').dataset.ready = '1';
try { const c = await fetch('/api/comments/' + id); document.getElementById('comments').textContent = 'Comments: ' + (await c.json()).count; }
catch { document.getElementById('comments').textContent = 'Comments unavailable offline'; }
document.getElementById('comments').dataset.ready = '1';
})();`;
let version = 1, down = false, hang = false, rev = 0;
const server = http.createServer((req, res) => {
if (req.url === '/__bump') { version++; return res.end('ok'); }
if (req.url === '/__down') { down = true; return res.end('ok'); }
if (req.url === '/__up') { down = false; return res.end('ok'); }
if (req.url === '/__hang') { hang = true; return res.end('ok'); }
if (req.url === '/__unhang') { hang = false; return res.end('ok'); }
if (req.url === '/__newsw') { rev++; return res.end('ok'); }
if (down) return req.destroy(); // simulate "offline": the connection dies
if (hang && req.url.startsWith('/api/article/')) return; // simulate a stalled network: accepted, never answered
const send = (type, body, extra = {}) => { res.writeHead(200, { 'content-type': type, ...extra }); res.end(body); };
const m = req.url.match(/^\/api\/(article|comments)\/(\d+)$/);
if (req.url === '/sw.js') return send('text/javascript', SW + '\n// rev ' + rev, { 'cache-control': 'no-cache' }); // rev makes a new worker byte-different
if (req.url === '/app.js') return send('text/javascript', APP);
if (req.url === '/shell.css') return send('text/css', 'body{font:16px sans-serif}');
if (m && m[1] === 'article' && m[2] === '6') { res.writeHead(500, { 'content-type': 'application/json' }); return res.end('{"error":true}'); } // article 6 is broken
if (m && m[1] === 'article') return send('application/json', JSON.stringify({ text: 'Article ' + m[2] + ' v' + version }));
if (m) return send('application/json', JSON.stringify({ count: version * 10 }));
send('text/html', '<!doctype html><link rel="stylesheet" href="/shell.css"><h1>News</h1><p id="body"></p><p id="comments"></p><script src="/app.js"></script>');
}).listen(0);
const base = `http://localhost:${server.address().port}`;
const fail = [];
const check = (ok, msg) => { console.log((ok ? 'PASS ' : 'FAIL ') + msg); if (!ok) fail.push(msg); };
const browser = await chromium.launch();
const ctx = await browser.newContext();
const page = await ctx.newPage();
const text = async (sel) => { await page.waitForSelector(sel + '[data-ready]'); return page.textContent(sel); };
const visit = async (id) => { await page.goto(`${base}/article/${id}`); return [await text('#body'), await text('#comments')]; };
await visit(1); // first visit installs the worker
await page.evaluate(() => navigator.serviceWorker.ready);
await page.waitForFunction(() => navigator.serviceWorker.controller);
console.log('online, first load :', await visit(1)); // now every request passes through the worker
await visit(2);
await fetch(base + '/__bump'); // the server publishes an update
const online = await visit(1);
console.log('online, after edit :', online);
check(online[0] === 'Article 1 v2' && online[1] === 'Comments: 20', 'online: the reader sees the updated article and the new comment count at once');
await fetch(base + '/__down'); // the network disappears
const off1 = await visit(1), off3 = await visit(3);
console.log('offline, visited :', off1);
console.log('offline, unvisited :', off3);
check(off1[0] === 'Article 1 v2', 'offline: a previously visited article is readable, at the version last seen');
check(off1[1] === 'Comments unavailable offline', 'offline: comments are never served stale; the page says they are unavailable');
check(off3[0] === 'This article is not saved for offline reading.', 'offline: an unvisited article gets an honest message inside the cached shell');
await fetch(base + '/__up');
await fetch(base + '/__hang'); // the network stalls: requests are accepted and never answered
const hung = await page.evaluate(async () => {
const t0 = performance.now();
try { const r = await fetch('/api/article/1', { signal: AbortSignal.timeout(8000) }); return [(await r.json()).text, performance.now() - t0]; }
catch { return ['no answer', performance.now() - t0]; }
});
await fetch(base + '/__unhang');
console.log('hanging network :', hung[0], hung[1] >= 2900 && hung[1] < 6000 ? '(after about 3 s)' : '(after ' + Math.round(hung[1]) + ' ms)');
check(hung[0] === 'Article 1 v2' && hung[1] >= 2900 && hung[1] < 6000, 'a stalled network is cut off after about 3 s and the cached copy is served');
for (const id of [1, 2, 3, 4, 5]) await visit(id); // five articles read, cache limit is 3
const articleKeys = () => page.evaluate(async () => (await (await caches.open('articles-v1')).keys()).map((r) => new URL(r.url).pathname));
const keys = await articleKeys();
console.log('article cache keys :', keys);
check(keys.length === 3 && keys.every((k) => ['/api/article/3', '/api/article/4', '/api/article/5'].includes(k)), 'the article cache keeps only the 3 most recently stored bodies');
await visit(6); // the server answers 500 for article 6
const keysAfter500 = await articleKeys();
check(keysAfter500.length === 3 && !keysAfter500.includes('/api/article/6'), 'a 500 response is never stored as the last copy');
await page.evaluate(() => caches.open('shell-v0').then((c) => c.put('/old', new Response('old deploy')))); // a cache left by an older deploy
await fetch(base + '/__newsw'); // sw.js is now byte-different, so the browser installs a new worker
await page.evaluate(() => navigator.serviceWorker.getRegistration().then((r) => r.update()));
let cleaned = false; // wait up to 10 s for the new worker to install and activate
for (let i = 0; i < 40 && !cleaned; i++) {
cleaned = !(await page.evaluate(() => caches.keys())).includes('shell-v0');
if (!cleaned) await page.waitForTimeout(250);
}
const names = (await page.evaluate(() => caches.keys())).sort();
console.log('caches after update:', names);
check(cleaned && names.join() === 'articles-v1,shell-v1', 'the new worker\'s activate step deletes the old deploy\'s cache and keeps the two current ones');
await browser.close(); server.close();
process.exit(fail.length ? 1 : 0);
Run in the Playwright 1.48.2 container (Chromium, headless), one sample run prints:
online, first load : [ 'Article 1 v1', 'Comments: 10' ]
online, after edit : [ 'Article 1 v2', 'Comments: 20' ]
PASS online: the reader sees the updated article and the new comment count at once
offline, visited : [ 'Article 1 v2', 'Comments unavailable offline' ]
offline, unvisited : [
'This article is not saved for offline reading.',
'Comments unavailable offline'
]
PASS offline: a previously visited article is readable, at the version last seen
PASS offline: comments are never served stale; the page says they are unavailable
PASS offline: an unvisited article gets an honest message inside the cached shell
hanging network : Article 1 v2 (after about 3 s)
PASS a stalled network is cut off after about 3 s and the cached copy is served
article cache keys : [ '/api/article/3', '/api/article/4', '/api/article/5' ]
PASS the article cache keeps only the 3 most recently stored bodies
PASS a 500 response is never stored as the last copy
caches after update: [ 'articles-v1', 'shell-v1' ]
PASS the new worker's activate step deletes the old deploy's cache and keeps the two current ones
exit=0
Reading the listing: SW is the worker's source, held in a string so the demo can serve it from /sw.js. install precaches three shell files; activate deletes any cache whose name is not on the allow-list (shell-v1, articles-v1); the fetch handler sends navigations to the cached /shell.html, shell files to the cache first, /api/article/* to networkFirst, and ignores /api/comments/*, which therefore goes to the network. networkFirst tries the network (with the 3,000 ms race), stores a copy of every successful response with cache.put(request, fresh.clone()) (a response body can be read only once, so one copy goes to the cache and the other to the page), trims the cache to the newest 3 entries (cache.keys() lists entries in the order they were added, so the first ones are the oldest), and on failure answers from the cache or with a small offlineMiss JSON error. The server takes the place of a real outage: after a request to /__down it destroys every incoming connection, which the browser sees as a network failure, the same way an offline phone would. /__bump raises the version so the test can tell old content from new. The test file's helper visit loads an article page and waits for the data-ready marker the page sets after its fetches finish, so no check runs on a half-loaded page. Three additions exist only for the later checks: /__hang makes article requests stall, so the 3,000 ms race is what returns the cached copy (the page-side fetch is given an 8-second AbortSignal.timeout so a missing race fails cleanly instead of hanging the test); article 6 always answers 500, which the fresh.ok test keeps out of the cache; and /__newsw makes sw.js byte-different, so registration.update() installs a new worker whose activate step deletes the leftover shell-v0 cache that the test created to stand for an older deploy.
What the output shows: after the server publishes an edit, an online reload returns Article 1 v2 and Comments: 20 straight away (network first, network only). With the server down, article 1 still opens from the cache at the version last seen, v2, comments say they are unavailable, and an article the reader never opened shows a message instead of a browser error page, because the shell itself is served from the precache. After five articles are read, the article cache holds only the newest three, since without a bound the cache grows with every page ever read. With the network stalled, article 1 is still answered from the cache after the 3 s timeout, a 500 for article 6 is shown as an error but not stored, and a new worker version removes the old deploy's cache while keeping the two current ones.
Cache lifecycle and pitfalls
- Updating the worker. The browser treats a worker as new only if the script is "byte-different" from the installed one. Cache names carry a version (
shell-v1), the install step fills the new shell, and theactivatestep deletes caches that are not on the allow-list, which web.dev names as the right moment to clear old caches. The demo servessw.jswithCache-Control: no-cache, an HTTP header telling the browser to ask the server whether its stored copy is still current before reusing it. That is a different cache from Cache Storage: the HTTP cache decides whether the browser re-downloads the worker script itself, and Cache Storage holds the pages and data the worker serves. It is a safeguard, not what makes updates work: by default (updateViaCache: 'imports') browsers ignore the HTTP cache when checking the top-level worker script for updates, and consult it only for scripts the worker pulls in withimportScripts(). The header onsw.jsitself therefore matters only when a page registers the worker withupdateViaCache: 'all'; for imported scripts the header has to be on those files. skipWaiting()andclients.claim(). The demo calls both so a first visit is controlled straight away. web.dev warns thatskipWaiting()"means that your new service worker is likely controlling pages that were loaded with an older version", so a deploy that changes the shell and the API shape together can briefly mismatch. If that matters, wait for the user to close tabs or show an "update available" prompt.- Only successful responses are stored. The demo checks
fresh.okbeforecache.put, so a 500 error page is not saved as the "last copy". - Authenticated or personalised content must not go into a cache shared across accounts on one device. Key or clear it per user.
- Storage is finite. The bound of 3 here is for the demo. A real limit is a number of articles you choose, and eviction is the code's job.
- Misses are normal. An offline reader opening a page that was never visited needs a designed answer, as in the demo, not a blank page.
Running the code
mkdir demo && cd demo && echo '{"type":"module"}' > package.json
npm i playwright@1.48.2 # save the listing above as sw-demo.mjs
docker run --rm -v "$PWD":/w -w /w mcr.microsoft.com/playwright:v1.48.2-jammy sh -c 'node sw-demo.mjs; echo exit=$?'
# add -e BREAK=cachefirst to serve articles cache first: FAIL lines and exit=1
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.
Write a minimal OpenAPI 3.1 specification (YAML) for a POST /orders endpoint on an e-commerce service. Include the request body schema (items: an array of objects with product_id and quantity), a 201 success response schema (order_id, total_amount), 400 and 401 error responses, and an example request and response embedded in the spec.
Sample Answer
The spec needs a request body schema for the array of order items, a 201 response schema for the created order, and explicit 400 and 401 error responses, each with an embedded example so a reader can see the exact shape without inferring it from the schema alone.
The specification
openapi: 3.1.0
info:
title: Orders API
version: "1.0.0"
paths:
/orders:
post:
operationId: createOrder
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
minItems: 1
items:
type: object
required: [product_id, quantity]
properties:
product_id:
type: string
quantity:
type: integer
minimum: 1
example:
items:
- product_id: "sku_1001"
quantity: 2
- product_id: "sku_2044"
quantity: 1
responses:
"201":
description: Order created
content:
application/json:
schema:
type: object
required: [order_id, total_amount]
properties:
order_id:
type: string
total_amount:
type: number
example:
order_id: "ord_7f3a"
total_amount: 64.50
"400":
description: Validation error
content:
application/json:
schema:
type: object
required: [error_code, message]
properties:
error_code:
type: string
message:
type: string
example:
error_code: "invalid_quantity"
message: "quantity must be at least 1"
"401":
description: Missing or invalid credentials
content:
application/json:
schema:
type: object
properties:
error_code:
type: string
example:
error_code: "unauthorized"
This document was parsed and structurally checked with a YAML parser: it parses cleanly as valid YAML, declares openapi: 3.1.0, defines the /orders path with the post operation described above, and every example object matches the shape its sibling schema declares (the 201 example has both order_id and total_amount, matching the required list; the 400 example has both error_code and message).
Design decisions worth calling out
itemsrequiresminItems: 1, so an order with zero line items is rejected at the schema level rather than reaching business logic.quantityhasminimum: 1, catching a zero-or-negative quantity the same way, at the contract layer.- The 400 response has a structured shape (
error_codeplusmessage), not a bare string, so a client can branch onerror_codeprogrammatically instead of pattern-matching on human-readable text.
Trade-offs and pitfalls
Keeping this spec minimal (one endpoint, three responses) makes it easy to read in an interview or a code review, but a production version would need to decide, and document, what OTHER error shapes exist (a 404 if referencing a nonexistent product_id, a 409 for a stock conflict) so the response schema doesn't quietly grow undocumented shapes over time as edge cases get patched in. A second real trade-off: embedding literal examples directly in the spec (as done here) is excellent for readability and for generating realistic mocks, but every example needs to be kept in sync with the schema by hand or by a linter; an example that drifts from its own schema is worse than no example, because it actively misleads a reader.
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