Full-Stack Developer Interview Questions · 2026

Full-Stack Developer Interview Questions (2026): Frontend, APIs & Databases

A full-stack candidate at a 12-person seed-stage startup got an odd question in April 2026, not "reverse this linked list," but "walk me through what happens between clicking Save on this form and the toast that says it worked." She nailed the validation step, the fetch call, and the API route. She stalled on the fifth beat, what the interface should do if that response takes four seconds instead of 200 milliseconds.

That's roughly the shape full-stack interview questions take once you get past the first round. The candidate before her had shipped a working CRUD take-home and still didn't get an offer, he'd never once mentioned what the UI does when a request fails halfway through. The loop borrows from frontend rounds (React, the browser, state), from backend rounds (a server runtime, REST or GraphQL, auth), and from database and system design rounds, then adds a fifth thing neither specialist track tests directly: whether you understand the seam between them.

The U.S. Bureau of Labor Statistics projects roughly 129,200 software developer openings a year through 2034, and a good share of those postings list "full stack" somewhere in the title, per the BLS Occupational Outlook Handbook. JavaScript still sits at the center of most of those postings. In the 2025 Stack Overflow Developer Survey, 68.8% of professional developers reported using JavaScript, TypeScript came in at 48.8%, and PostgreSQL led the database rankings at 58.2%. If you only have time to sharpen one stack combination before a loop, that's roughly the one the market is actually hiring for.

One opinion here, and plenty of hiring managers would push back on it: full-stack loops at small and mid-size companies test breadth over depth on purpose, and that's the right call for how those teams actually work, one engineer often owns a feature end to end. At a company running strict separate frontend and backend org charts, the same breadth-first loop would probably miss real gaps in either direction. I don't have data on how these questions land inside that kind of split organization, our sessions skew toward smaller, cross-functional teams, so treat the large-enterprise version of this list with some caution.

4-6Rounds
LC MediumCoding
React, APIs & SQLCore Focus
3-5 weeksPrep Time

Frontend: where most full-stack interview questions start

Frontend questions in a full-stack loop rarely stop at syntax. Interviewers want to see whether you understand what the browser is doing while your API call is still in flight, since that's the part candidates skip over when they've only ever built the happy path.

Easy questions

15

JavaScript runs on a single thread. When you call something async, fetch, setTimeout, a database query from a Node backend, the call itself returns immediately and the actual work happens elsewhere (the browser's network stack, Node's libuv thread pool, the OS). The callback or promise resolution gets queued, and the event loop only picks it up once the call stack is empty. Two queues matter here: microtasks (promises, queueMicrotask) drain completely before the loop touches the next macrotask (setTimeout, I/O, a UI paint).

For a full-stack app specifically, this explains why a component renders once, then updates again a beat later, once the fetch resolves. Candidates who describe the event loop correctly but can't connect it to "why does my UI flicker between a loading state and real data" are missing the part interviewers actually care about.

Server Components render on the server and never ship their JavaScript to the browser. They can read from a database or call an internal API directly, but they can't use hooks or event handlers, since there's no client runtime attached to them.

Use them for the parts of a page that are mostly a data fetch and a render, a product page, a blog body, a dashboard sidebar of numbers nobody clicks on. Leave the interactive leaves of the tree, a form, a filter, a button with an onClick, as client components. Converting an entire app to Server Components isn't the goal. Shrinking the client bundle by moving data-only subtrees off it is.

Client-side validation is for the user, instant feedback on a malformed email before they hit submit, no round trip needed. Server-side validation is for the data, since anyone can bypass your frontend entirely and hit the API directly with curl or Postman.

Skipping server validation because "the form already checks it" is one of the more common gaps in a full-stack take-home. Interviewers will sometimes just call your API directly during review to see if it holds up without the form in front of it.

Server-side rendering (SSR) generates HTML per request, good for pages with data that changes often and needs to be current, a user's dashboard. Static generation (SSG) builds HTML at deploy time, good for content that barely changes, a marketing page or a blog post. Client-side rendering (CSR) ships a mostly empty shell and lets the browser fetch and render everything, fine for an app behind a login where SEO doesn't matter and the user already expects a brief load.

The honest answer most interviewers want isn't a rule, it's that you'd default to SSG where you can get away with it (cheapest to serve, fastest to the user), reach for SSR when the data is genuinely per-request, and use CSR for the authenticated parts of the app where a search engine will never see the page anyway.

Node hands off I/O work, database queries, file reads, network calls, to the operating system or to libuv's thread pool, and keeps its own single thread free to handle the next incoming request while that work happens elsewhere. When the I/O finishes, its callback gets queued and the event loop picks it up once the call stack is empty.

The part candidates miss: this only works for I/O-bound work. A synchronous CPU-heavy calculation, resizing an image in pure JS, hashing a large payload, blocks that single thread completely, and every other request waits behind it, even ones with nothing to do with the slow calculation. The fix is a worker thread or a separate job queue, not a smaller setTimeout.

Middleware is a function that sits between the incoming request and your route handler, and can inspect, modify, or reject the request before it ever reaches your business logic. Auth checks, request logging, and body parsing are all middleware.

javascript
function requireAuth(req, res, next) {
 const token = req.cookies.token;
 if (!token) return res.status(401).json({ error: 'Not authenticated' });

 try {
  req.user = jwt.verify(token, process.env.JWT_SECRET);
  next(); // pass control to the next middleware or the route handler
 } catch {
  res.status(401).json({ error: 'Invalid token' });
 }
}

app.get('/api/profile', requireAuth, (req, res) => {
 res.json({ userId: req.user.sub });
});

The detail that trips people up in a live round: forgetting to call next(), which leaves the request hanging with no response at all, not even an error, until it eventually times out.

One canonical list of required environment variables, checked into the repo as an example file (.env.example) with no real values, and a startup check that fails loudly if a required variable is missing rather than silently falling back to undefined. The drift usually comes from someone adding a new variable locally, forgetting to add it anywhere else, and staging quietly running with a stale default for three weeks before anyone notices.

Secrets themselves shouldn't live in that checked-in file at all, a secrets manager or your host's environment variable settings, but the list of what's required should be visible and versioned like any other code.

PostgreSQL leads for a reason, 58.2% of professional developers reported using it in the 2025 Stack Overflow Developer Survey, the highest of any database in the ranking. Relational structure and real ACID transactions solve most application problems, users, orders, permissions, without the consistency tradeoffs a document store introduces.

Reach for MongoDB when your data's shape genuinely varies row to row, or you're prototyping something whose schema will change weekly and you don't want a migration for every change. The trap: picking Mongo for "flexibility" on data that's actually quite relational, then rebuilding joins by hand in application code because the database won't do it for you.

An index, usually a B-tree, lets the database find rows without scanning the whole table, the difference between checking every page of a book and jumping straight to the right one using its index.

The cost side is what separates a complete answer from a half one: every index has to be updated on every insert, update, and delete to that table, so write-heavy tables with too many indexes slow down noticeably on writes. Indexes aren't free performance, they're a trade of write speed for read speed. For query-writing practice beyond what a full-stack loop usually tests, our SQL interview questions page goes further.

A migration file is a versioned, ordered, repeatable record of every schema change, checked into the same repo as the code that depends on it. Editing a production database by hand works exactly once, until someone forgets what they changed, or a second environment (staging, a new hire's local setup) needs the identical change applied and there's no record of what "identical" even means.

Migrations also make schema changes reviewable in a pull request the same way code is, and reversible, a well-written migration has a down step that undoes it if a deploy needs to roll back.

A health check is a lightweight endpoint (often just /health) that returns 200 if the app can actually serve traffic, ideally checking that it can reach its database, not just that the process is running.

The load balancer polls it regularly and pulls an instance out of rotation the moment it starts failing, before real user requests hit a broken instance. Skipping the database check inside the health endpoint is a common miss, an app whose process is up but whose database connection died still looks "healthy" to a load balancer checking only that the process responds.

Your Node API layer is usually the easy case for horizontal scaling, since a stateless API server (no session data sitting in its own memory) can run as many identical instances behind a load balancer as traffic needs. Your Postgres primary is the harder case, you can add read replicas to scale reads horizontally, but writes still funnel through one primary, since that's what keeps data consistent.

Vertical scaling the primary, a bigger instance, more RAM, buys time and is the simplest lever to pull, but it has a ceiling and doesn't solve a genuinely write-heavy workload the way sharding or a different data architecture eventually would. Our system design interview questions page covers sharding strategies if the interviewer pushes past this point.

PUT replaces the entire resource with whatever you send in the request body. PATCH applies a partial update, only the fields you include change, everything else on the server stays as it was. A PATCH request with just { "email": "new@x.com" } should leave the user's name and password hash untouched. Send that same partial body as a PUT and, strictly speaking, the server is supposed to wipe out every field you didn't include, because the client is asserting "this is the full resource now."

Idempotency matters because REST semantics are a contract with anyone building against your API, including retry logic in your own frontend. PUT is supposed to be idempotent, calling it once or five times with the same body leaves the resource in the same final state, so it's safe for a client to retry a PUT after a network timeout without side effects. POST isn't idempotent, retrying a POST can create five duplicate records, which is exactly why payment and order-creation endpoints bolt on idempotency keys. PATCH isn't required to be idempotent either, though most simple field updates behave that way in practice.

Authentication answers "who are you." It's the login step, checking a password, verifying a JWT signature, confirming a session cookie maps to a real row in your users table. Once that's done, the app knows the request came from user 4821.

Authorization answers "what are you allowed to do." It's the check that runs after authentication, does user 4821 own this document, is user 4821 an admin, is this feature enabled on their plan tier. A request can be fully authenticated and still get rejected. A logged-in user hitting DELETE /users/99 when they're not an admin is authenticated but not authorized.

This gets asked so often because the two get conflated in code, and that's exactly where security bugs hide. Middleware that only checks "is there a valid token" is doing authentication. If the route handler doesn't separately check that the token's user actually owns the resource being touched, any logged-in user can read or edit someone else's data just by changing an id in the URL. That's an authorization bug wearing an authentication fix's clothes.

Offset pagination is LIMIT 20 OFFSET 40, page 3 of 20 results. It's simple to build and lets a user jump straight to page 7. The catch is that the database still has to scan and discard every row before the offset, so OFFSET 100000 on a large table gets slow, and if rows are inserted or deleted between page loads, users see duplicates or skipped rows because the "position" shifts under them mid-session.

Cursor pagination sends back a pointer instead, usually the last row's id or created_at, and the next request asks for rows after that cursor.

sql
SELECT * FROM posts
WHERE created_at < :cursor
ORDER BY created_at DESC
LIMIT 20;

That query hits an index instead of scanning and skipping, and it stays correct even while new rows are being inserted, since each page is anchored to a real row rather than a shifting number. Use cursors for infinite scroll feeds, activity logs, or anything with heavy write volume. Offset pagination is fine for an admin table with a few thousand rows where people actually want page numbers and jump-to-page. What you give up with cursors is random access, you can't jump to "page 50" without walking through the cursors to get there.

Medium questions

26

A closure keeps its outer scope alive even after the function that created it returns. In a useEffect, that usually means an event listener or a timer holds a reference to state or props from the render that created it, and if the effect never cleans that listener up, the closure, and everything it references, stays in memory even after the component unmounts.

The tell in the browser is a growing heap in Chrome DevTools' Memory tab across route changes that should have released the component, plus detached DOM nodes that never got collected. The fix is almost always the same: return a cleanup function from useEffect that removes the exact listener or clears the exact timer you added. Missing that return statement is one of the most common bugs candidates ship in a live coding round without noticing.

A component re-renders when its own state changes, when its parent re-renders (even with identical props, unless it's wrapped in React.memo), or when a context value it consumes changes. That last case is the one that surprises people once an app grows, a single context update can re-render a much larger subtree than expected.

For the deeper mechanics, reconciliation, key stability, why an index as a key breaks list diffing, our React developer interview questions page goes further than a full-stack loop usually needs. What a full-stack interviewer wants here is simpler: can you explain why moving state one level down, or splitting a context in two, fixes an unnecessary re-render, without reaching for memoization first.

Update the UI immediately, assuming the request will succeed, then reconcile with reality once the response actually comes back. If it fails, revert to the previous state and, ideally, surface why.

jsx
function LikeButton({ postId, initiallyLiked }) {
 const [liked, setLiked] = useState(initiallyLiked);

 async function handleClick() {
  const previous = liked;
  setLiked(!previous); // optimistic

  try {
   await api.toggleLike(postId);
  } catch (err) {
   setLiked(previous); // roll back
   showToast('Could not save your like, try again');
  }
 }

 return <button onClick={handleClick}>{liked ? 'Liked' : 'Like'}</button>;
}

Interviewers watch for whether you remember the rollback path at all, plenty of candidates write the happy path and stop there, and whether you handle a second click arriving while the first request is still in flight. A more complete answer disables the button, or tracks a request id, until the in-flight call resolves.

The browser is enforcing the same-origin policy: your frontend, running on one origin, is calling an API on a different origin, and the browser blocks the response from reaching your JavaScript unless the server explicitly allows it via an Access-Control-Allow-Origin header (and, for anything beyond a simple GET, a preflight OPTIONS request the server also has to answer correctly).

It's a server-side fix, not a frontend one, no amount of frontend code can make a server return the right CORS headers. The trip-up candidates make: setting Access-Control-Allow-Origin: * on an endpoint that also needs to accept cookies or credentials, which the spec doesn't allow together. If you need credentials, the server has to echo back the specific origin, not a wildcard.

Two common approaches. Refetch the whole list after the mutation succeeds, simplest to reason about, but wasteful if the list is large and mostly unchanged. Or patch the local cache directly, appending the new item to what you already have in memory, which feels instant but means you're now responsible for keeping that cache correct by hand.

Tools like React Query or SWR split the difference: they let you optimistically patch the cache immediately, then quietly revalidate against the server in the background to catch anything you got wrong. Candidates who've only ever used raw useState plus fetch tend to reinvent a rougher version of this and miss the revalidation step entirely.

Any environment variable prefixed for client exposure, VITE_, NEXT_PUBLIC_, REACT_APP_, gets baked directly into the JavaScript bundle at build time and ships to every browser that loads the page. Opening the network tab or the bundled JS file reveals it in plain text. There's no such thing as a secret on the frontend.

Anything that needs to stay secret, a payment provider's secret key, a database connection string, belongs in a server-only variable that never gets that public prefix, and the frontend calls your own backend, which calls the third party using the real secret. If a key needs to be in the browser at all, it should be scoped down to the least privilege that key can have, the way a Stripe publishable key is deliberately safe to expose while its secret key counterpart is not.

Version it in the URL path, /v1/, rather than a header, since a URL is something every client, every log line, and every teammate can see at a glance. Enforce one consistent error shape across every endpoint (the same field names for a status code, a message, a request id), and commit an OpenAPI spec to the repo so CI can catch a contract-breaking change before it ships, not after a mobile client crashes in production.

Our backend developer interview questions page goes deeper on idempotency keys and cursor pagination if you want the full version of this question. For a full-stack loop, the shorter version usually suffices: naming consistency and a documented contract matter more day to day than any specific versioning scheme you pick.

Sessions are simpler and trivially revocable, delete the row and the user's logged out everywhere, but they assume a server-side store (Redis, usually) and a client that reliably sends a cookie back. JWTs are stateless and self-contained, which pays off once more than one client type, a web app and a native mobile app, both need to authenticate against the same API without a shared cookie jar.

My honest take: don't switch to JWTs today just because mobile might show up later. Sessions with Redis handle a web-only app fine, and migrating auth later, when a real mobile client actually exists, is a bounded, well-understood piece of work. Building JWT revocation infrastructure for a client that doesn't exist yet is complexity paid for speculatively.

A browser attaches cookies to a request automatically, including one triggered by a malicious site the user happens to have open in another tab, which is exactly what cross-site request forgery exploits. A bearer token sitting in an Authorization header isn't attached automatically by the browser, an attacker's page has no way to make your browser add that header on its behalf, so the same attack simply doesn't apply the same way.

This is one reason some full-stack teams store the access token in memory (a JS variable) rather than any form of storage the browser sends automatically, accepting that it disappears on refresh, and rely on a separate httpOnly refresh-token cookie with SameSite protections to quietly get a new one.

This is the N+1 problem wearing a GraphQL costume, one query for the list, then N more queries, one per row, for a nested field. GraphQL makes it easier to accidentally write, since each field resolver looks independent and nobody notices the loop hiding underneath the schema.

javascript
const authorLoader = new DataLoader(async (authorIds) => {
 const authors = await db.users.findByIds(authorIds); // one batched query
 return authorIds.map(id => authors.find(a => a.id === id));
});

const resolvers = {
 Post: {
  author: (post) => authorLoader.load(post.authorId),
 },
};

DataLoader batches every load() call made within the same tick into a single query, then hands each caller its own result once the batch resolves, and caches repeated requests for the same id within that request. It doesn't fix a badly designed schema, it fixes the query pattern underneath a reasonable one.

Fixed window (100 requests per minute per key, reset on the minute) is simple but lets a client burst 100 requests in the last second of one window and another 100 in the first second of the next, effectively 200 in two seconds. Token bucket avoids that, each client accrues tokens at a steady rate and spends one per request, so bursts get smoothed rather than clustered at window edges.

Interviewers usually ask what you return when a client hits the limit, a 429 status with a Retry-After header, not a silent drop, so well-behaved clients know exactly when to try again instead of hammering the endpoint blind.

Don't make the HTTP request wait 45 seconds for an answer, most clients and proxies will time out well before that, and the user is left staring at a spinner with no idea if it's still working. Accept the request, push a job onto a queue (BullMQ, SQS, whatever your stack already runs), and return immediately with a job id.

The frontend then polls a status endpoint or listens on a WebSocket for a "done" event, and shows the result once it's ready. Interviewers push on the failure case here specifically: what happens if the worker crashes mid-job, and whether the job gets retried, silently dropped, or shown to the user as failed so they can retry it themselves.

This is the moment a full-stack developer's job differs from a pure backend or pure frontend one, you have to know which side of the wire to look on before you can even start debugging. A consistent 900ms on one endpoint, not spiky, not tied to a specific input, usually points at the database rather than the network or the app server, so the next step is timing the query in isolation, not adding more frontend loading states to hide it.

Run EXPLAIN ANALYZE on the actual query the endpoint issues, not a simplified version of it, and look for a sequential scan where an index scan would be expected. Frequently the real culprit is an ORM silently issuing N+1 queries behind a single-looking function call, which shows up in the frontend as one slow request but is actually dozens of tiny ones stacked on the server.

A transaction guarantees all three steps commit together or none of them do. Without one, a crash between decrementing inventory and charging the card leaves you with inventory gone and no payment collected, or worse, a payment collected with no order record to fulfill it.

The follow-up interviewers ask: what isolation level, and why does it matter here specifically? Two checkouts hitting the same low-stock item concurrently need enough isolation that both can't read "1 in stock" and both decrement to -1. A row-level lock (SELECT... FOR UPDATE) or a database constraint preventing negative inventory closes that gap. Read committed, the default in Postgres, isn't automatically enough on its own without one of those two guards.

No, not meaningfully. Composite indexes are left-anchored, the database can only use the index efficiently if your query includes the leftmost column, user_id here, in its filter. A query on created_at alone has to fall back to a sequential scan, or a separate index on created_at by itself, because the composite index's internal ordering is sorted by user_id first.

Candidates who get this wrong usually assume any column inside a composite index gets indexed benefits independently, which isn't how the underlying B-tree structure works. If both query patterns are common, you likely need two separate indexes, not one that tries to serve both.

Each database connection costs real memory and file descriptors on the database server, so Postgres and most databases cap the total number allowed. If your app opens a new connection per request instead of reusing a small pool, or if slow queries hold connections open longer than usual under load, you can exhaust that cap fast during a spike.

The fix is a connection pool sized to your actual database's connection limit, not your app server's request volume, plus a queue with a timeout so a request that can't get a connection fails fast with a clear error instead of hanging indefinitely. PgBouncer in front of Postgres is the common answer at real scale, since it lets you support far more app-level connections than Postgres itself would tolerate directly.

Soft delete (a deleted_at column, filtered out of normal queries) preserves history, supports an "undo" feature, and keeps foreign key references intact for anything still pointing at that row. Hard delete actually removes the row, simpler, but anything still referencing it needs a plan, cascade the delete, or block it if references exist.

The cost of soft delete that candidates miss: every single query in the codebase now needs a WHERE deleted_at IS NULL clause, and forgetting it once means a "deleted" row quietly reappears somewhere it shouldn't. Some teams handle this with a database view or an ORM-level default scope so the filter can't be forgotten by accident.

Explicit invalidation, delete or overwrite the Redis key the moment the update transaction commits, is the accurate option, but it means finding every code path that can change a profile and remembering to invalidate from each one. A TTL as a backstop (say, five minutes) catches anything an explicit invalidation missed, at the cost of accepting up to that TTL's worth of staleness in the worst case.

Most real systems run both together rather than picking one, explicit invalidation for the common paths, a TTL so a missed edge case doesn't serve wrong data forever. Our backend developer interview questions page covers write-through and write-behind caching in more depth if the interviewer goes further than a profile cache.

A monorepo makes a shared TypeScript type (the shape of an API response, say) trivial, both sides import from the same source of truth and a mismatch fails at compile time instead of showing up as a runtime bug in production. It also means one pull request can update the frontend and backend together for a single feature.

Separate repos give each side its own deploy cadence and its own CI pipeline without one team's slow test suite blocking the other's release. Smaller teams tend to prefer a monorepo, since the type-safety win outweighs the coordination cost when it's the same few people touching both sides anyway. Once frontend and backend are owned by genuinely separate teams with different release schedules, the coordination cost of a shared repo starts to outweigh the benefit.

Block on anything that means the app is broken for users: failing tests, a type error, a linter rule that catches a real bug class (not a style preference), a build that doesn't compile. Warn on things that are a judgment call, bundle size creeping up, a new dependency with a moderate-severity advisory, test coverage dropping a percentage point.

Teams that block on everything end up disabling checks under deadline pressure, which defeats the purpose. Teams that block on nothing ship broken builds. The honest answer is you tune this over time based on what actually caused an incident once, not by pre-deciding a "correct" list before you've shipped anything.

Rolling deployment replaces instances gradually, a few at a time, so you're never running zero capacity, but old and new code run side by side briefly. Blue-green stands up an entirely separate environment, tests it, then flips traffic over all at once, which makes rollback nearly instant, just flip back, at the cost of running double the infrastructure during the switch.

A schema-breaking migration changes the calculus either way, since old code and new code briefly coexist during any rollout, and old code querying a column you just renamed or dropped will crash. The safe pattern is expand-then-contract: add the new column without removing the old one, deploy code that can read either, backfill data, then remove the old column in a later, separate deploy once nothing references it anymore.

Static assets that are identical for every user, JS bundles, images, fonts, CSS, belong on a CDN without much debate, they're the same bytes for everyone and benefit from edge caching close to the user. Public, cacheable API responses that don't vary per user (a product catalog page, say) can go on a CDN too, with a sensible cache-control header and a way to purge it on update.

Anything containing a user's personal data, an authenticated dashboard response, a payment confirmation, should never sit behind a shared cache layer without very careful per-user cache keys, since a misconfigured CDN cache is exactly how one user's private data has ended up served to a different user in real incidents.

A feature flag lets you deploy code to production without exposing it to users yet, decoupling "the code is live on the server" from "users can see it." That separation is what makes a rollback nearly instant, flip the flag off, no redeploy needed, versus reverting a commit and waiting for a full deploy pipeline to run again.

It also lets you roll a feature out to 1% of users first, watch error rates and key metrics, then widen it gradually instead of finding out at 100% that something's broken. The cost: flags that never get cleaned up pile up as permanent branches in your code, and a codebase with forty stale flags is its own kind of technical debt.

Generate a request id at the very first point the request enters your system (a client-side interceptor, or the edge or load balancer), and pass it through every layer, attach it to every log line, every downstream service call's headers, and any error you report. Without that shared id, correlating a slow frontend request with the specific backend log lines and the specific slow query that caused it means guessing based on timestamps.

Structured logging (JSON logs with consistent fields, not free-text strings) makes that request id actually searchable across services instead of just present. A tracing tool (OpenTelemetry, or a vendor's APM) builds on the same idea, stitching spans from each layer into one timeline you can look at as a whole instead of three separate log streams you have to correlate by hand.

Push as much of the defense to the edge as you can, a CDN or reverse proxy layer that rate-limits by IP and blocks obviously malicious patterns before a request ever costs you a database connection or a line of application code executing. Your app-level rate limiter is still worth having as a second layer, but it's already paying the cost of a full request reaching your server by the time it runs.

For anything sensitive, login attempts, password resets, layering in a CAPTCHA or a progressive delay after repeated failures adds friction specifically for automated abuse without much cost to a real user who just mistyped a password once.

Polling (the client asks "anything new?" every few seconds) is the simplest to build and debug, and fine for anything that doesn't need to feel instant. Server-sent events (SSE) give you a one-way stream from server to client over plain HTTP, simpler than a WebSocket to set up and reconnects automatically in most browsers, but they can't send data back up to the server over that same connection.

WebSockets are the right call when you need genuinely two-way, low-latency communication, a chat feature, a collaborative editor, live interview feedback where the client and server both need to talk continuously. The cost is real: WebSockets need their own connection management, reconnect logic, and don't sit as cleanly behind some older load balancer configurations as plain HTTP does. Don't reach for a WebSocket because it sounds more sophisticated than polling when polling every three seconds would serve the feature just as well for a fraction of the complexity.

Hard questions

11

Two separate problems are stacked here. First, you're firing far more requests than you need, a debounce that waits until typing pauses cuts that down. Second, and this is the one that actually causes wrong results on screen, responses can come back out of order: if the request for "re" resolves after the request for "react" (a slower connection, a flaky server), the stale "re" results overwrite the correct "react" results.

The fix for ordering is an AbortController, cancel the previous request the moment a new one fires, so a canceled request's response never reaches your state setter.

javascript
let controller;

async function search(query) {
 if (controller) controller.abort(); // cancel the in-flight request
 controller = new AbortController();

 try {
  const res = await fetch(`/api/search?q=${query}`, { signal: controller.signal });
  const data = await res.json();
  setResults(data);
 } catch (err) {
  if (err.name !== 'AbortError') throw err;
 }
}

Debouncing alone doesn't fully solve this, a debounce reduces how often you fire, it doesn't guarantee response order once requests are in flight over an unreliable network. Candidates who reach for only one of the two fixes usually get asked what happens on a slow connection to see if the gap shows up.

Not every failure deserves a retry. A 500 from a flaky downstream service is worth retrying. A 400 for a malformed request will fail identically every time, retrying just delays the inevitable and wastes the user's patience.

javascript
async function fetchWithRetry(url, options = {}, retries = 3, delay = 300) {
 for (let attempt = 0; attempt <= retries; attempt++) {
  const res = await fetch(url, options);
  if (res.ok || res.status < 500) return res; // don't retry client errors

  if (attempt === retries) return res; // out of attempts, return the failure

  await new Promise(r => setTimeout(r, delay * 2 ** attempt)); // 300ms, 600ms, 1200ms...
 }
}

The follow-up interviewers usually ask: what stops this from hammering a server that's already struggling? A small amount of random jitter added to each delay prevents every failed client from retrying in lockstep at the exact same moment, which is exactly the kind of synchronized retry storm that turns a brief blip into an outage.

The client posts email and password over HTTPS. The server looks up the user, compares the submitted password against the stored hash (bcrypt or argon2, never a plain comparison), and on success issues a token. For a full-stack app, storing that token in an httpOnly cookie, rather than localStorage, is the detail interviewers listen for, since JavaScript can't read an httpOnly cookie, which closes off a whole class of XSS token theft.

javascript
app.post('/login', async (req, res) => {
 const user = await db.users.findByEmail(req.body.email);
 const valid = user && await bcrypt.compare(req.body.password, user.passwordHash);
 if (!valid) return res.status(401).json({ error: 'Invalid credentials' });

 const token = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: '15m' });
 res.cookie('token', token, { httpOnly: true, secure: true, sameSite: 'strict' });
 res.json({ ok: true });
});

Every subsequent request to a protected route runs through middleware that reads the cookie, verifies the token's signature, and attaches the decoded user to the request before the route handler ever runs. Candidates who describe issuing the token but skip what verifies it on every later request are only telling half the story.

Store the provider's event id the first time you see it, with a unique constraint on that column, and check it before processing. If a duplicate arrives, look up the already-recorded result and return success without repeating the side effect, crediting an account, marking an order paid.

The part that actually breaks under load: a naive "check if seen, then insert" has a race condition when both deliveries land within milliseconds of each other. The database-level unique constraint is what closes that gap, letting the second insert fail fast so your handler can catch that specific error and treat it as "already processed" instead of trusting an application-level check alone.

Start by narrowing what "intermittent" means, is it every Nth request, only during traffic spikes, or tied to a specific route. A 502 means the load balancer got a bad or no response from the upstream server, so the actual cause usually lives in one of three places: the Node process is CPU-blocked on something synchronous and timing out, the database connection pool is exhausted and requests are queuing behind it, or the load balancer's health check is flapping and briefly pulling a healthy instance out of rotation.

Check connection pool metrics first if the timing correlates with load, an exhausted pool under a traffic spike is the single most common cause I've seen candidates walk through, and it's usually fixable by right-sizing the pool and adding a queue timeout rather than just raising the pool size until it stops happening (raising it just moves where the app breaks, from your app to the database).

Run EXPLAIN ANALYZE first, to see the actual plan the database is choosing right now, not the plan you assume it's using. Look specifically for a sequential scan somewhere you'd expect an index scan.

The usual suspects, roughly in order of how often I've seen each one: a missing index on a column that used to be fast enough back when the table was small, a function wrapped around the indexed column in the WHERE clause (WHERE LOWER(email) =...), which blocks a plain index unless a matching expression index exists, stale table statistics after a large bulk import, fixed with a manual ANALYZE, or the table simply crossing a size threshold where a plan that used to favor an index scan now costs more than a sequential one under the query planner's estimate. Jumping straight to "add an index" before confirming the plan is the tell that someone's guessing rather than diagnosing.

Confirm the correlation first, is the spike actually tied to this deploy, or a coincidence (a dependency having its own outage right now, a traffic spike unrelated to your release). Check the error rate trend against the deploy timestamp specifically before assuming causation.

If it's the deploy, roll back immediately rather than trying to hotfix forward under pressure, a rollback returns you to a known-good state in minutes, a forward fix risks introducing a second bug while you're already firefighting the first one. Once you're back on stable ground, then dig into what broke, with logs and a calmer head, and ship the actual fix as its own deploy. The instinct to "just fix it fast" while still on the broken version is understandable and, in my experience watching this play out, usually the wrong call.

Adding a column with a NOT NULL constraint and a default value in one step forces Postgres to rewrite every existing row to backfill that default, which can hold a lock on the table for the entire operation, long enough to time out real user requests on a table that size.

sql
-- step 1: add the column as nullable, no rewrite needed, fast
ALTER TABLE users ADD COLUMN plan_tier TEXT;

-- step 2: backfill in small batches, not one giant UPDATE
UPDATE users SET plan_tier = 'free'
WHERE id BETWEEN 1 AND 10000 AND plan_tier IS NULL;
-- repeat in batches across the full id range

-- step 3: once fully backfilled, add the NOT NULL constraint
ALTER TABLE users ALTER COLUMN plan_tier SET NOT NULL;

Splitting it into three steps, add nullable, backfill in small batches with a brief pause between each, then constrain, keeps every individual statement fast enough that it doesn't hold a long lock. Application code has to tolerate a temporarily-null column during the backfill window, which is the part candidates forget to mention until asked directly.

This is read-after-write consistency breaking, and it almost always means the write landed on a primary database while the very next read got routed to a read replica that hasn't caught up yet. Replication from primary to replica is asynchronous in Postgres streaming replication and in most managed MySQL or Postgres setups, so there's a real window, often tens of milliseconds, sometimes much longer under load, where the replica is still serving the row as it looked before the write. The user's browser doesn't care that it's "only" 50ms behind, their own edit just vanished.

The fix depends on how much control you have over read routing. The cleanest option is sticky reads, after a write, route that user's next few reads, or reads for the rest of that request, back to the primary, then fall back to replicas afterward. Some setups do this properly by checking replication lag against a known LSN or sequence position and only using a replica once it's caught up past the write. A blunter but common approach is just to always read your own writes from the primary and let everything else hit replicas.

The failure mode that actually bites teams is when this isn't a deliberate design decision at all, it's an accident from bolting on read replicas for scaling and pointing a load balancer at all of them round robin with no read-your-writes guarantee anywhere. It looks fine in staging with a single database and shows up as "random" bugs in production nobody can reproduce, because it depends entirely on which replica got hit and how far behind it happened to be at that exact moment.

A join table, saved_jobs, with user_id, job_id, and a saved_at timestamp, plus a composite primary key on (user_id, job_id) so the same user can't save the same job twice. Both foreign keys get their own index, since you'll query this table from either direction, a user's saved list, or a job's total save count.

sql
CREATE TABLE saved_jobs (
 user_id BIGINT REFERENCES users(id),
 job_id BIGINT REFERENCES jobs(id),
 saved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
 PRIMARY KEY (user_id, job_id)
);

SELECT j.*
FROM jobs j
JOIN saved_jobs s ON s.job_id = j.id
WHERE s.user_id = $1
ORDER BY s.saved_at DESC;

The composite primary key does double duty, it enforces the no-duplicate-save rule at the database level instead of an application-side check that races under concurrent clicks, and it's already an index the "which jobs has this user saved" query can use directly.

Start with the traffic shape, not the schema: reads (redirects) vastly outnumber writes (creating a new short link) in almost every real system like this, so your design should optimize the read path first. Generate short codes with base-62 encoding of an auto-incrementing id, or a dedicated id-generation service if you're running multiple app servers and can't rely on a single auto-increment counter.

Cache hot codes in Redis in front of the database, since a small fraction of links account for most daily redirects, and use a 302 redirect rather than 301 if you want click analytics, a 301 gets cached by the browser itself and bypasses your server on the next click entirely. Our system design interview questions page walks through the full read and write scaling math for this exact problem if the interviewer wants more depth than a full-stack loop usually goes.

Before you design anything

Interviewers are watching how you spend the first three minutes more than the diagram itself. Confirm scale (how many users, how many requests a second), confirm what "fast" means for this feature (200ms? two seconds?), and confirm what happens on failure, before you draw a single box. Candidates who jump straight to a solution usually have to backtrack once a constraint they didn't ask about turns out to change everything.

How to prepare when you can't specialize in just one layer

Full-stack interview questions don't reward splitting your prep time evenly across four categories. SQL and system design fundamentals repeat across nearly every full-stack loop regardless of company size or which frontend framework the job posting mentions, the wording changes, the underlying concept (an index, a transaction, a stale cache) doesn't. Framework trivia, the exact syntax for a specific state management library, teaches itself on the job in the first two weeks anyway.

Across mock full-stack sessions run through LastRoundAI, the stall point almost never lands in the middle of one clean explanation. It lands in the handoff, a candidate walks through the API route correctly, then goes quiet the moment the question shifts to what the interface does while that request is still in flight, or a candidate nails the frontend state update and can't say what happens on the server if two of those updates arrive at once. Practicing that specific handoff out loud, not just the individual pieces, is what most candidates skip.

I don't have great data on how this differs at companies running strict frontend and backend org charts, our sessions skew toward smaller, cross-functional teams where one engineer owns a feature end to end, so treat anything here about a 200-person engineering org with a grain of salt. What I'd still bet on: the demand for people who can hold the whole stack in their head isn't shrinking, software developer openings keep growing well ahead of the average occupation per the BLS, and most of those postings don't specify a narrow specialty.

Get the reps in before the real thing

Reading through all 48 full-stack interview questions above is not the same as defending one out loud when an interviewer asks a follow-up you didn't expect. If a specific concept here, isolation levels, the event loop, a CAP-theorem-adjacent tradeoff, still feels shaky, a concept explainer session breaks it down the way an interviewer actually tests it, not the textbook version. For live practice with real-time follow-ups, an interview copilot mode mirrors that back-and-forth so the first time you explain a rollback strategy under pressure isn't during the actual interview.

LastRoundAI's mock interview practice runs through frontend, backend, database, and system design rounds live, and the free plan includes 15 credits a month that reset monthly, Starter is $19/mo for more sessions. If interview performance isn't the bottleneck and finding enough full-stack openings that actually fit is, Auto-Apply finds and applies to matching roles for you, with every application queued for your review before anything sends. Questions about either product: contact@lastroundai.com.

LastRound data

What we see on our side

Across 1,393 interview sessions configured on LastRound between January 2025 and July 2026, 97 were set up for full-stack development. That is a small sample and we are not going to dress it up as more, but it is first-hand rather than borrowed, and it is the pool these questions were sanity-checked against.

Frequently asked questions

Do full-stack interviews go deeper on frontend or backend?

Usually backend, even for roles advertised as balanced. Data modelling, API design and query performance carry more weight in most loops because they are harder to reverse later. Expect to be assessed as competent on the frontend and rigorous on the backend.

How much system design is in a full-stack interview?

More than it used to be, and it starts earlier in the ladder. Mid-level full-stack candidates are now routinely asked to sketch a small end-to-end system, including where state lives and how the client stays in sync.

Should I specialise before interviewing for full-stack roles?

Have one side you can go genuinely deep on. Interviewers are usually comfortable with a candidate who is strong on one half and solid on the other; what tends to score badly is being surface-level on both.

What trips full-stack candidates up most?

Explaining data flow across the boundary. Candidates describe the frontend and the backend clearly, then get vague about what happens between them: caching, invalidation, optimistic updates and error states.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

AI Interview Copilot
Get live help in your interview

LastRoundAI listens to the call and suggests clear, structured answers to questions like the ones above, in real time and invisible on screen share.

Leave a Reply

Your email address will not be published. Required fields are marked *