Full-Stack Developer Interview Questions
Core Overview
Prepare for full-stack developer interviews covering frontend and backend integration, API data flows, persistence, authentication, deployment, testing, performance, and system design.
Ready to test your knowledge?
Launch a focused practice session to review questions without distraction.
What information forms the HTTP contract between a frontend and a backend?
Direct Answer
The contract includes the method, URL, headers, request body, status codes, response schema, authentication requirements, errors, and compatibility expectations.
Detailed Explanation
A frontend and backend communicate through an explicit contract rather than through shared assumptions.
The contract commonly defines:
GET, POST, PUT, PATCH, or DELETE.The frontend should not infer success only from receiving JSON. It should inspect the HTTP status and validate that the response matches the expected runtime structure.
The backend should not expose database rows or framework exceptions directly as its public contract. Internal implementation can change without forcing every frontend consumer to change.
A good contract also distinguishes expected business failures, such as validation or conflict errors, from unexpected server failures.
Code Example
POST /api/applications
Content-Type: application/json
Authorization: Bearer <token>
{
"jobId": "job-42",
"resumeId": "resume-7"
}
HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/applications/application-91
{
"id": "application-91",
"jobId": "job-42",
"status": "saved",
"createdAt": "2026-08-05T18:00:00Z"
}Common Interview Pitfalls
- Treating an undocumented response shape as a stable API contract.
- Returning database entities directly to frontend clients.
- Using HTTP 200 for every success and failure outcome.
- Changing required fields without considering older frontend versions.
- Displaying raw backend exception messages directly to users.
How should a full-stack developer decide whether work belongs on the server or in the browser?
Direct Answer
Keep secrets, privileged data access, and server-oriented rendering on the server; use client code for browser APIs, local interaction state, and immediate user interactions.
Detailed Explanation
The server and browser have different capabilities and trust boundaries.
Work commonly belongs on the server when it requires:
Work commonly belongs in a Client Component or browser module when it requires:
localStorageA server-rendered component can fetch data close to the source and send rendered output or serialized component data to the browser. A client component can then add interactivity around the server-produced content.
The client-server boundary should remain narrow. Marking a high-level application component as client-side can pull a large subtree and its dependencies into the browser bundle.
Server rendering does not remove the need for API authorization. A client can still send direct requests, so every protected server operation must enforce permissions independently.
Code Example
// Server Component
export default async function ApplicationsPage() {
const applications =
await applicationRepository.findForCurrentUser();
return (
<ApplicationList
initialApplications={applications}
/>
);
}
// Client Component
'use client';
export function ApplicationList({
initialApplications
}: {
initialApplications: ApplicationSummary[];
}) {
const [query, setQuery] = useState('');
const visible = initialApplications.filter(
(application) =>
application.company
.toLowerCase()
.includes(query.toLowerCase())
);
return (
<>
<input
value={query}
onChange={(event) =>
setQuery(event.target.value)
}
/>
<ApplicationCards applications={visible} />
</>
);
}Common Interview Pitfalls
- Importing secret-bearing server modules into client-side code.
- Marking an entire page hierarchy as client-side for one interactive control.
- Performing authorization only while rendering the frontend.
- Accessing browser APIs from code that executes on the server.
- Duplicating the same initial data request on both server and client.
Why should full-stack applications use DTOs and runtime validation at system boundaries?
Direct Answer
DTOs define transport-specific contracts, while runtime validation proves that untrusted request and response data actually matches those contracts before use.
Detailed Explanation
A TypeScript type or backend class improves development-time safety, but it does not automatically validate information received over HTTP.
Data crossing a system boundary should be treated as untrusted. This includes:
A Data Transfer Object, or DTO, represents the shape exchanged across that boundary. It should remain separate from database entities and internal domain objects when those models have different responsibilities.
A useful flow is:
1. Receive an unknown external value.
2. Validate its structure and constraints at runtime.
3. Convert it into a trusted command or domain value.
4. Execute business rules.
5. Map the result into a response DTO.
This prevents transport-specific concerns such as serialized dates, nullable legacy fields, provider names, or database columns from spreading through the whole application.
Type assertions such as response as User do not inspect the value at runtime. They only tell the TypeScript compiler to trust the developer’s claim.
Code Example
type ApplicationResponse = {
id: string;
status: 'saved' | 'applied' | 'interview';
createdAt: string;
};
function parseApplicationResponse(
value: unknown
): ApplicationResponse {
if (
typeof value !== 'object' ||
value === null ||
!('id' in value) ||
!('status' in value) ||
!('createdAt' in value) ||
typeof value.id !== 'string' ||
typeof value.createdAt !== 'string' ||
![
'saved',
'applied',
'interview'
].includes(String(value.status))
) {
throw new Error(
'Invalid application response'
);
}
return {
id: value.id,
status: value.status as
ApplicationResponse['status'],
createdAt: value.createdAt
};
}
async function fetchApplication(
id: string
): Promise<ApplicationResponse> {
const response = await fetch(
`/api/applications/${encodeURIComponent(id)}`
);
if (!response.ok) {
throw new Error(
`Request failed: ${response.status}`
);
}
const body: unknown = await response.json();
return parseApplicationResponse(body);
}Common Interview Pitfalls
- Casting JSON directly to a TypeScript interface without validation.
- Using database entities as request and response DTOs.
- Trusting browser validation as the backend’s only validation layer.
- Allowing provider-specific fields to spread through domain logic.
- Returning sensitive internal fields because they exist on the source entity.
How should a frontend remain consistent when it sends a mutation to the backend?
Direct Answer
Track the mutation lifecycle, prevent unintended duplicates, reconcile authoritative server results, invalidate affected data, and roll back optimistic changes when necessary.
Detailed Explanation
A mutation changes authoritative state outside the browser. Examples include creating an application, changing its status, deleting a saved job, or updating a profile.
The frontend should account for several stages:
A pessimistic update waits for the backend result before changing the visible state. It is simpler when failure is common or the server determines the final result.
An optimistic update changes the UI immediately and later reconciles with the server. It can improve perceived responsiveness, but requires:
The backend response should be treated as authoritative because it may add identifiers, timestamps, normalized values, calculated fields, or a different final state.
After success, the frontend may update its cache directly or invalidate affected queries. Invalidating too broadly can create unnecessary refetching, while failing to invalidate related data can leave stale counts and lists.
For operations that may be retried after a timeout, the backend may need idempotency protection.
Code Example
async function updateApplicationStatus(
applicationId: string,
status: ApplicationStatus
): Promise<Application> {
const response = await fetch(
`/api/applications/${applicationId}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ status })
}
);
if (!response.ok) {
const problem: unknown =
await response.json();
throw parseApiProblem(problem);
}
const body: unknown = await response.json();
return parseApplication(body);
}
// On success, replace the local cached entity
// with the canonical server response.Common Interview Pitfalls
- Allowing repeated clicks to create unintended duplicate mutations.
- Keeping optimistic data after the backend rejects the operation.
- Ignoring normalized or calculated values returned by the server.
- Invalidating every cached request after one small mutation.
- Displaying raw backend exception text as the user-facing error.
- Assuming a network timeout proves that the mutation did not complete.
How should a browser frontend and backend integrate securely using cookie-based sessions?
Direct Answer
Use opaque secure cookies, validate the session server-side, enforce authorization on every request, and protect state-changing operations against cross-site request forgery.
Detailed Explanation
A cookie-based session commonly works as follows:
1. The user authenticates through a protected login flow.
2. The backend creates a server-side session or a securely validated session representation.
3. The server sends an opaque session cookie.
4. The browser automatically includes the cookie on matching requests.
5. The backend validates the session and authorizes the requested resource operation.
Important cookie attributes include:
Secure so the cookie is sent only over HTTPS.HttpOnly so ordinary JavaScript cannot read it.SameSite to restrict some cross-site transmission.Path and appropriate lifetime.Because browsers may automatically send cookies, state-changing requests require protection against Cross-Site Request Forgery, or CSRF. Depending on the architecture, controls can include SameSite cookies, anti-CSRF tokens, origin validation, and requiring non-simple request headers.
CORS is not a substitute for CSRF protection, authentication, or authorization.
The frontend should not determine security from whether a navigation link or button is visible. The backend must validate identity, tenant scope, ownership, and operation permission for every protected request.
Code Example
HTTP/1.1 200 OK
Set-Cookie: session=opaque-session-id;
Path=/;
Secure;
HttpOnly;
SameSite=Lax;
Max-Age=3600
POST /api/applications/application-91/archive
Cookie: session=opaque-session-id
X-CSRF-Token: signed-request-token
Origin: https://app.example.comCommon Interview Pitfalls
- Treating a hidden frontend button as an authorization boundary.
- Using cookie sessions without considering CSRF protection.
- Storing session identifiers in insecure non-HttpOnly cookies.
- Trusting a user or tenant identifier supplied by the browser.
- Using CORS as a replacement for server-side authorization.
- Returning protected data before completing ownership checks.
When is a backend-for-frontend useful, and how should its responsibilities be bounded?
Direct Answer
A backend-for-frontend adapts downstream services to one frontend’s needs, centralizing aggregation, authentication translation, and response shaping without duplicating core business ownership.
Detailed Explanation
A Backend for Frontend, or BFF, is a server-side layer designed around the needs of a particular frontend experience.
It can be useful when the frontend must:
For example, a dashboard page may require profile completion, application counts, recent jobs, and subscription state. A BFF can retrieve those sources and return one page-oriented response.
The BFF should not become an unowned second business domain. Core business invariants should remain in the authoritative services or domain layer.
A healthy BFF boundary can own:
It should avoid:
Fan-out requests need deadlines, concurrency controls, and a policy for partial failure. Optional dashboard data may degrade independently, while security or payment state may require the entire response to fail closed.
Code Example
type DashboardResponse = {
profile: ProfileSummary;
applications: ApplicationSummary;
recommendations:
| {
status: 'available';
jobs: RecommendedJob[];
}
| {
status: 'temporarily-unavailable';
};
};
export async function getDashboard(
user: AuthenticatedUser
): Promise<DashboardResponse> {
const profilePromise =
profileService.getSummary(user.id);
const applicationsPromise =
applicationService.getSummary(user.id);
const recommendationsPromise =
recommendationService
.getForUser(user.id)
.catch(() => null);
const [
profile,
applications,
recommendations
] = await Promise.all([
profilePromise,
applicationsPromise,
recommendationsPromise
]);
return {
profile,
applications,
recommendations:
recommendations === null
? {
status: 'temporarily-unavailable'
}
: {
status: 'available',
jobs: recommendations
}
};
}Common Interview Pitfalls
- Moving core business rules into a frontend-specific aggregation layer.
- Creating a BFF that only proxies every request without adaptation.
- Allowing the BFF to read and write service-owned databases directly.
- Running unbounded request fan-out without deadlines or concurrency controls.
- Returning partial data without making degraded fields explicit.
- Implementing authorization inconsistently from authoritative backend services.
- Creating one universal BFF with unrelated responsibilities for every client.
- Hiding critical downstream failures behind successful but incorrect responses.
How should a REST-style API use resources, HTTP methods, and status codes?
Direct Answer
Model domain concepts as resources, apply HTTP methods according to their defined semantics, and return status codes that accurately describe each request outcome.
Detailed Explanation
A resource-oriented API exposes domain concepts through stable identifiers such as /applications, /jobs, and /users/{userId}.
HTTP methods communicate the intended operation:
Status codes should communicate the HTTP-level result:
200 OK for a successful response containing a representation.201 Created after creating a resource, often with a Location header.204 No Content when the operation succeeds without a response body.400 Bad Request for malformed request syntax or general invalid input.401 Unauthorized when valid authentication credentials are required.403 Forbidden when the authenticated principal is not permitted.404 Not Found when the target resource is unavailable or intentionally concealed.409 Conflict when the request conflicts with current resource state.422 Unprocessable Content when the content is understood but cannot be processed according to its instructions.500 Internal Server Error for an unexpected server failure.The response body can provide domain-specific information, but it should not contradict the selected status code.
Resource paths should describe domain concepts rather than exposing controller names or database operations. However, not every operation maps naturally to basic create, read, update, and delete behavior. Explicit action resources can be appropriate when they represent meaningful domain transitions.
Code Example
POST /api/applications
Content-Type: application/json
{
"jobId": "job-42",
"resumeId": "resume-7"
}
HTTP/1.1 201 Created
Location: /api/applications/application-91
Content-Type: application/json
{
"id": "application-91",
"jobId": "job-42",
"status": "saved"
}
PATCH /api/applications/application-91
Content-Type: application/json
{
"status": "applied"
}
HTTP/1.1 200 OK
Content-Type: application/json
{
"id": "application-91",
"jobId": "job-42",
"status": "applied"
}Common Interview Pitfalls
- Using POST for every operation without considering HTTP method semantics.
- Returning a success status when the response body describes an error.
- Using 401 and 403 interchangeably without considering authentication state.
- Returning 200 for newly created resources without communicating their location.
- Designing endpoint paths around database tables or controller method names.
- Using DELETE for a reversible domain transition that is not actually deletion.
How should an API support pagination, filtering, and sorting for collection endpoints?
Direct Answer
Define stable query parameters, enforce bounded page sizes, use deterministic ordering, and return enough navigation metadata for clients to retrieve additional results safely.
Detailed Explanation
Collection endpoints should avoid returning an unbounded number of records. Pagination limits response size, database work, memory usage, and frontend rendering cost.
Two common pagination models are:
Offset pagination
limit and offset, or page and pageSize.Cursor pagination
Sorting must be deterministic. If several records share the primary sort value, include a stable secondary key such as the resource ID.
Filtering parameters should have documented names, types, allowed values, and combination rules. The backend must validate filters rather than inserting raw query values into database expressions.
Page sizes should have defaults and enforced maximums. An API should not allow a client to bypass limits by requesting an extremely large value.
Navigation can be communicated through response metadata, opaque cursors, or standardized HTTP links. Clients should treat cursors as opaque and avoid constructing or modifying them.
Code Example
GET /api/applications
?status=applied
&sort=-updatedAt
&limit=20
&cursor=eyJ1cGRhdGVkQXQiOiIyMDI2LTA4LTA1In0
HTTP/1.1 200 OK
Content-Type: application/json
Link: </api/applications?status=applied&limit=20&cursor=next-cursor>;
rel="next"
{
"items": [
{
"id": "application-91",
"status": "applied",
"updatedAt": "2026-08-05T18:00:00Z"
}
],
"page": {
"nextCursor": "next-cursor",
"hasMore": true
}
}Common Interview Pitfalls
- Returning an unbounded collection because the current dataset is small.
- Using unstable ordering that produces duplicate or missing records across pages.
- Allowing clients to request an unlimited page size.
- Exposing database-specific cursor details as a public client contract.
- Accepting arbitrary filter fields without validation or allowlisting.
- Using high offset values without evaluating their database performance.
- Calculating an expensive exact total count for every collection request unnecessarily.
How should an API define a consistent error contract using HTTP Problem Details?
Direct Answer
Use the appropriate HTTP status and return a stable problem object with a type, title, status, detail, instance, and documented extension fields when needed.
Detailed Explanation
Clients need a predictable error structure that is separate from internal exceptions and log output.
RFC 9457 defines Problem Details for HTTP APIs using the media type application/problem+json.
Standard members include:
Applications may add extension members such as:
The HTTP status remains authoritative. A problem response should not contain status: 400 while the actual HTTP response uses 200.
Clients should make decisions using stable machine-readable values such as the status, problem type, or documented error code—not by comparing human-readable messages.
Problem details should not expose stack traces, SQL errors, access tokens, internal paths, or sensitive account information.
Validation errors may include a documented extension containing field names and safe messages. The client must still handle unknown fields because newer backend versions may add information.
Code Example
HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/validation",
"title": "Request validation failed",
"status": 422,
"detail": "One or more fields are invalid.",
"instance": "/api/applications/request-482",
"code": "APPLICATION_VALIDATION_FAILED",
"errors": [
{
"field": "jobId",
"message": "A valid job identifier is required."
}
],
"traceId": "trace-8241"
}Common Interview Pitfalls
- Returning a different JSON error shape from every endpoint.
- Using a successful HTTP status for a problem response.
- Making clients parse human-readable detail text to determine behavior.
- Returning stack traces or database error messages to the browser.
- Using a generic 500 response for expected validation or conflict failures.
- Assuming clients will recognize every future extension field.
- Including sensitive resource information in an authorization failure.
How should an API support safe retries without performing a mutation more than once?
Direct Answer
Use method semantics and idempotency keys, persist the first operation result atomically, reject conflicting key reuse, and retry only appropriate transient failures.
Detailed Explanation
A client can lose the response even when the server successfully completes a request. A timeout therefore creates uncertainty: retrying might be necessary, but it might also repeat the mutation.
HTTP defines some methods as idempotent. Repeating an idempotent request is intended to have the same effect as performing it once, although each response may not be identical.
Operations such as creating a payment or application may require an idempotency key.
A typical flow is:
1. The client creates a unique key for one logical operation.
2. The client sends the key with the mutation.
3. The server stores the key, a request fingerprint, execution status, and final result.
4. A retry with the same key and equivalent request returns the stored result.
5. Reusing the same key for different input is rejected.
The idempotency record and business mutation must be coordinated atomically. Otherwise two concurrent requests can both observe that the key is absent and perform duplicate work.
Retries should use bounded exponential backoff, often with jitter. Clients should retry only failures likely to be transient, such as selected network failures, timeouts, rate limits, or temporary server unavailability.
Validation failures, authorization failures, and permanent conflicts generally should not be retried without changing the request.
Code Example
POST /api/applications
Idempotency-Key: 622df034-8cf4-40db-b029-cb362b77e164
Content-Type: application/json
{
"jobId": "job-42",
"resumeId": "resume-7"
}
// Simplified server flow:
await database.transaction(async (transaction) => {
const existing =
await transaction.idempotencyRecords.findForUpdate(
idempotencyKey
);
if (existing) {
assertMatchingFingerprint(
existing.requestFingerprint,
requestFingerprint
);
return existing.response;
}
const application =
await transaction.applications.create(command);
await transaction.idempotencyRecords.insert({
key: idempotencyKey,
requestFingerprint,
response: application
});
return application;
});Common Interview Pitfalls
- Retrying a non-idempotent mutation without an operation identifier.
- Generating a new idempotency key for each retry of the same operation.
- Allowing one key to be reused with different request content.
- Checking for an existing key outside the mutation transaction.
- Retrying validation and authorization failures without changing the request.
- Using immediate unlimited retries that amplify an outage.
- Assuming a client timeout proves that the server performed no work.
How do HTTP caching, freshness, validators, and conditional requests work together?
Direct Answer
Cache controls define whether and how long responses may be reused, while validators such as ETag let clients revalidate stale representations without downloading them again.
Detailed Explanation
HTTP caching can reduce latency, bandwidth, server load, and repeated computation.
A cached response may be considered fresh for a period defined by response metadata such as Cache-Control: max-age=60. A fresh response can normally be reused without contacting the origin server.
When a cached response becomes stale, a client or intermediary can send a conditional request using a validator.
Common validators include:
A client can send If-None-Match with a previously received ETag. If the selected representation has not changed, the server can return 304 Not Modified without sending the full representation again.
Important cache directives include:
public: The response may be stored by shared caches when other rules permit.private: The response is intended for a private cache and should not be stored by shared caches.no-store: The response should not be stored.no-cache: A stored response must be successfully validated before reuse.max-age: Defines freshness lifetime in seconds.Responses containing personalized data require careful cache-key and privacy decisions. A shared cache must not serve one user’s private response to another user.
The Vary header identifies request headers that influenced response selection. Omitting a required Vary value can cause an intermediary to reuse the wrong representation.
Code Example
GET /api/jobs/job-42 HTTP/1.1
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=60
ETag: "job-42-version-8"
{
"id": "job-42",
"title": "Full-Stack Developer"
}
GET /api/jobs/job-42 HTTP/1.1
If-None-Match: "job-42-version-8"
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "job-42-version-8"Common Interview Pitfalls
- Assuming no-cache means that a response cannot be stored.
- Caching personalized responses publicly without a safe cache key.
- Generating unstable ETags that change when the representation does not.
- Returning a full response when a conditional request can use 304.
- Omitting a required Vary header for negotiated representations.
- Caching authorization-sensitive responses without reviewing privacy implications.
- Using long freshness periods without a strategy for changed data.
How would you design a resilient API data flow from the browser through the backend and its dependencies?
Direct Answer
Define contracts and deadlines, validate every boundary, control retries and concurrency, preserve idempotency, expose safe errors, instrument the flow, and degrade deliberately.
Detailed Explanation
A resilient data flow treats failure, delay, duplication, cancellation, and partial availability as expected system conditions.
A practical design includes the following layers.
Browser boundary
API contract
Backend execution
Downstream dependencies
Caching and consistency
Observability
A resilient endpoint should have an explicit failure policy. For example, a job details page may still render if recommendations are unavailable, while authorization or subscription state should fail closed.
Code Example
type JobPageResponse = {
job: JobDetails;
application:
| {
status: 'available';
value: ApplicationSummary | null;
}
| {
status: 'temporarily-unavailable';
};
recommendations:
| {
status: 'available';
values: JobSummary[];
}
| {
status: 'temporarily-unavailable';
};
};
async function getJobPage(
user: AuthenticatedUser,
jobId: string,
signal: AbortSignal
): Promise<JobPageResponse> {
const jobPromise = jobsService.getRequired(
jobId,
{ signal }
);
const applicationPromise = applicationsService
.findForUserAndJob(user.id, jobId, { signal })
.then((value) => ({
status: 'available' as const,
value
}))
.catch(() => ({
status: 'temporarily-unavailable' as const
}));
const recommendationsPromise = recommendationsService
.getRelated(jobId, { signal })
.then((values) => ({
status: 'available' as const,
values
}))
.catch(() => ({
status: 'temporarily-unavailable' as const
}));
const [
job,
application,
recommendations
] = await Promise.all([
jobPromise,
applicationPromise,
recommendationsPromise
]);
return {
job,
application,
recommendations
};
}Common Interview Pitfalls
- Applying the same retry policy to every operation and failure type.
- Allowing optional dependency failure to take down an entire page unnecessarily.
- Hiding critical authorization or payment failures as optional degradation.
- Performing unbounded parallel fan-out to downstream services.
- Logging credentials or sensitive payloads for debugging convenience.
- Using timeouts without cancellation or resource cleanup.
- Returning inconsistent error formats from different execution branches.
- Retrying mutations without idempotency or duplicate protection.
- Collecting latency metrics without dependency or trace context.
How do primary keys, foreign keys, unique constraints, check constraints, and nullability protect relational data?
Direct Answer
Relational constraints enforce identity, references, uniqueness, allowed values, and required fields inside the database rather than relying only on application validation.
Detailed Explanation
A relational database protects data through schema-level constraints.
Primary key
Foreign key
Unique constraint
Check constraint
Nullability
NOT NULL requires a value to be present.Application validation improves usability and can produce better error messages, but it cannot replace database constraints. Two concurrent requests can both pass an application-level duplicate check before either inserts its row. A database uniqueness constraint remains the final protection.
Constraints should enforce rules that must always hold regardless of whether data is written by the web application, a background job, an administrative script, or another service.
Code Example
CREATE TABLE users (
id UUID PRIMARY KEY,
normalized_email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE job_applications (
id UUID PRIMARY KEY,
user_id UUID NOT NULL,
external_job_id TEXT NOT NULL,
status TEXT NOT NULL,
applied_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_application_user
FOREIGN KEY (user_id)
REFERENCES users (id)
ON DELETE CASCADE,
CONSTRAINT uq_user_external_job
UNIQUE (user_id, external_job_id),
CONSTRAINT chk_application_status
CHECK (
status IN (
'saved',
'applied',
'interview',
'offer',
'rejected'
)
),
CONSTRAINT chk_applied_date
CHECK (
status = 'saved' OR applied_at IS NOT NULL
)
);Common Interview Pitfalls
- Relying only on frontend or application validation for critical integrity rules.
- Using a mutable business label as the primary key.
- Creating foreign-key columns without defining appropriate delete behavior.
- Allowing null values without defining what absence means.
- Checking for duplicates in application code without a database uniqueness constraint.
- Assuming a unique index and every unique business rule are automatically identical concepts.
What is a database transaction, and what do the ACID properties mean?
Direct Answer
A transaction groups related database work into one unit. ACID describes atomicity, consistency, isolation, and durability guarantees around that unit.
Detailed Explanation
A transaction defines a boundary around database operations that must be treated as one logical unit.
The ACID properties are commonly described as follows:
For example, consuming an AI credit and recording the generated output may need one transaction. Deducting the credit without recording the operation—or recording the operation without deducting the credit—would create inconsistent state.
A transaction should usually be scoped to the smallest complete business operation. Keeping transactions open while waiting for user input or slow external network calls can hold locks, retain database resources, and increase conflict risk.
Database transactions do not automatically make external APIs transactional. If an operation includes a database write and an email, payment, or message-broker call, the design needs an additional coordination strategy such as an outbox, idempotent consumer, compensation, or workflow state.
An application should also handle transaction failures. Deadlocks, serialization failures, and connectivity problems may require a carefully bounded retry when the complete operation is safe to repeat.
Code Example
BEGIN;
SELECT remaining_credits
FROM ai_credit_balances
WHERE user_id = 'user-42'
FOR UPDATE;
UPDATE ai_credit_balances
SET remaining_credits = remaining_credits - 1
WHERE user_id = 'user-42'
AND remaining_credits >= 1;
INSERT INTO ai_credit_ledger (
id,
user_id,
amount,
reason
)
VALUES (
'ledger-91',
'user-42',
-1,
'resume_analysis'
);
COMMIT;Common Interview Pitfalls
- Performing related database writes without one transaction.
- Keeping a transaction open while calling a slow external service.
- Assuming a database transaction also rolls back an email or HTTP request.
- Catching a transaction error and committing partial work manually.
- Retrying a failed transaction when the entire operation is not safely repeatable.
- Using a transaction without checking whether the intended invariant is actually protected.
How do database indexes improve queries, and what tradeoffs should a full-stack developer understand?
Direct Answer
Indexes provide faster paths to selected rows and ordered results, but they consume storage and add maintenance work to inserts, updates, and deletes.
Detailed Explanation
A database index stores an additional structure that helps the database locate rows without scanning every row in a table.
Indexes can support:
An index should be designed around actual query patterns. For example, an application frequently loading one user’s applications ordered by recent activity may benefit from a composite index beginning with user_id and then updated_at.
Column order matters in a multicolumn index. An index on (user_id, updated_at) is not generally equivalent to one on (updated_at, user_id).
Indexes have costs:
Adding an index does not guarantee that the optimizer will use it. A sequential scan may be more efficient when a query returns much of the table or the table is small.
Query performance should be investigated using the database execution plan and representative data. Developers should examine estimated and actual row counts, scan types, join strategies, sorting, filter selectivity, and time spent.
An index can also be partial, covering only rows matching a predicate, which can be useful for frequently queried subsets such as active applications.
Code Example
CREATE INDEX idx_applications_user_updated
ON job_applications (
user_id,
updated_at DESC,
id DESC
);
CREATE INDEX idx_active_applications_user
ON job_applications (
user_id,
updated_at DESC
)
WHERE status IN (
'saved',
'applied',
'interview',
'offer'
);
EXPLAIN ANALYZE
SELECT
id,
status,
updated_at
FROM job_applications
WHERE user_id = 'user-42'
ORDER BY
updated_at DESC,
id DESC
LIMIT 20;Common Interview Pitfalls
- Adding an individual index to every column without considering write cost.
- Designing indexes without examining real query patterns.
- Ignoring column order in a composite index.
- Assuming an index is used merely because it exists.
- Testing execution plans only against an almost empty development database.
- Using functions or type conversions that prevent an intended index access path.
- Adding overlapping indexes that provide little additional value.
What benefits and risks do ORMs introduce, and how should developers handle migrations and N+1 queries?
Direct Answer
ORMs simplify mapping and common persistence work, but developers must still inspect generated queries, control relationship loading, and deploy schema migrations deliberately.
Detailed Explanation
An Object-Relational Mapper can provide:
An ORM does not eliminate the need to understand relational modeling, SQL, indexes, transactions, locking, and execution plans.
A common ORM performance problem is the N+1 query pattern:
1. One query loads a collection of N parent records.
2. Accessing one related value for each parent triggers another query.
3. The operation executes one initial query plus up to N additional queries.
The solution depends on the use case and ORM. Options can include an explicit join, eager loading, batch fetching, projection into a DTO, or a separate query that loads all needed related records.
Loading every relationship eagerly is not a general solution. It can create oversized joins, duplicated result rows, excessive memory use, or unnecessary data transfer.
Schema migrations should be:
Automatically applying migrations from every application instance during startup can create concurrency, permission, availability, and recovery risks.
Code Example
// Potential N+1 pattern:
const applications =
await database.application.findMany({
where: {
userId
}
});
for (const application of applications) {
const job = await database.job.findUnique({
where: {
id: application.jobId
}
});
console.log(job?.title);
}
// Better: fetch the required projection together.
const applicationCards =
await database.application.findMany({
where: {
userId
},
select: {
id: true,
status: true,
job: {
select: {
title: true,
companyName: true
}
}
}
});Common Interview Pitfalls
- Assuming ORM-generated queries are automatically efficient.
- Loading a related record separately inside a loop.
- Solving every N+1 query by eagerly loading the entire object graph.
- Applying production migrations without reviewing generated operations.
- Running migrations automatically from multiple application instances.
- Changing a required column in one deployment without supporting older application versions.
- Using ORM abstractions without monitoring query counts and execution time.
How does optimistic concurrency control prevent lost updates?
Direct Answer
Optimistic concurrency includes an expected version in the update condition and rejects the write when another transaction has already changed the record.
Detailed Explanation
A lost update can occur when two clients read the same record and later save changes based on that old version.
Example:
1. Client A reads version 4.
2. Client B reads version 4.
3. Client A updates the record, creating version 5.
4. Client B saves its version-4 edit without checking freshness.
5. Client B unintentionally overwrites Client A’s change.
Optimistic concurrency assumes conflicts are uncommon and avoids holding a database lock throughout the user interaction.
A record includes a concurrency token such as:
The update includes both the record identifier and the expected token:
UPDATE ... WHERE id = ? AND version = ?
If the affected-row count is zero, the record was deleted or changed since the client read it. The application should report a conflict rather than silently overwriting the newer state.
The response can let the user reload, compare changes, merge selected fields, or retry using the latest version.
Optimistic concurrency differs from optimistic UI. Optimistic UI changes the browser before server confirmation, while optimistic concurrency protects persistent data from stale writes.
Code Example
UPDATE job_applications
SET
notes = $1,
version = version + 1,
updated_at = NOW()
WHERE id = $2
AND user_id = $3
AND version = $4
RETURNING
id,
notes,
version,
updated_at;
// If no row is returned, respond with a conflict
// rather than overwriting a newer version.Common Interview Pitfalls
- Updating a record by identifier without checking the version that was read.
- Treating a zero-row update as an ordinary successful save.
- Using a low-precision timestamp that cannot reliably identify every change.
- Automatically retrying a stale write with the new version and overwriting changes anyway.
- Confusing optimistic concurrency control with optimistic frontend updates.
- Returning conflict details without verifying resource ownership first.
How would you design a reliable persistence layer for a growing full-stack product?
Direct Answer
Define domain invariants, enforce critical integrity in the database, use explicit transactions and concurrency controls, evolve schemas safely, and observe real query behavior.
Detailed Explanation
A reliable persistence layer should protect correctness while remaining understandable, observable, and evolvable.
Data modeling
Access boundaries
Transactions and concurrency
Query performance
Schema evolution
Use backward-compatible expand-and-contract changes when old and new application versions may run simultaneously:
1. Add new nullable structures or parallel fields.
2. Deploy code that supports both representations.
3. Backfill data safely and observably.
4. Switch reads and writes to the new representation.
5. Add stricter constraints after validation.
6. Remove old structures in a later release.
Operations and security
Reliability should be protected by integration tests that run against a real database engine for transaction, constraint, migration, and concurrency-sensitive behavior.
Code Example
async function createApplication(
command: CreateApplicationCommand
): Promise<Application> {
return database.transaction(async (transaction) => {
const job = await transaction.jobs.findById(
command.jobId
);
if (!job || !job.isActive) {
throw new DomainError(
'JOB_NOT_AVAILABLE'
);
}
try {
return await transaction.applications.insert({
id: createId(),
userId: command.userId,
jobId: command.jobId,
status: 'saved',
version: 1
});
} catch (error: unknown) {
if (isUniqueConstraintViolation(error)) {
throw new DomainError(
'APPLICATION_ALREADY_EXISTS'
);
}
throw error;
}
});
}
// Database uniqueness remains the final protection
// against concurrent duplicate submissions.Common Interview Pitfalls
- Enforcing every critical invariant only in application code.
- Using one generic repository that prevents access to necessary database features.
- Applying destructive schema changes before all application versions support them.
- Adding indexes without monitoring write cost or actual query use.
- Testing persistence behavior only through mocked repositories.
- Using runtime credentials with broad schema modification permissions.
- Retaining sensitive data indefinitely without a documented requirement.
- Collecting slow-query data without associating it with application operations.
- Assuming backups are reliable without testing restoration.
What is the difference between authentication and authorization?
Direct Answer
Authentication establishes who a principal is, while authorization decides whether that authenticated principal may perform a specific action on a resource.
Detailed Explanation
Authentication establishes the identity of a user, service, or device. It answers the question: “Who is making this request?”
Authentication may use:
Authorization determines whether the authenticated principal is allowed to perform an operation. It answers questions such as:
Authorization should be evaluated on the server for every protected operation. Hiding a button or route in the frontend improves the user experience but does not prevent a user from calling the underlying API directly.
A robust authorization decision may consider:
Authentication should occur before authorization, but successful authentication does not imply permission to access every resource.
Concrete applications should generally deny access when no explicit rule permits the operation. This is commonly described as deny by default.
Code Example
type AuthenticatedUser = {
id: string;
roles: string[];
tenantId: string;
};
async function getApplication(
user: AuthenticatedUser,
applicationId: string
): Promise<Application> {
const application =
await applicationRepository.findById(
applicationId
);
if (!application) {
throw new NotFoundError();
}
const canRead =
application.userId === user.id &&
application.tenantId === user.tenantId;
if (!canRead) {
throw new ForbiddenError();
}
return application;
}Common Interview Pitfalls
- Treating successful login as permission to access every application resource.
- Enforcing authorization only by hiding controls in the frontend.
- Trusting a user or tenant identifier supplied in the request body.
- Checking a broad role while ignoring resource ownership or tenant scope.
- Using authentication and authorization as interchangeable terms.
- Allowing access whenever no explicit authorization rule matches.
How do server sessions and JWT-based tokens differ, and where should browser authentication material be stored?
Direct Answer
Server sessions keep authoritative session state on the server, while JWTs carry signed claims. Browser storage must be chosen according to XSS, CSRF, expiry, and revocation risks.
Detailed Explanation
A server-side session commonly works by storing session data in a database or distributed cache and sending the browser an opaque session identifier.
Advantages include:
Costs include maintaining a shared session store when the application runs across multiple instances.
A JSON Web Token, or JWT, contains claims encoded into a signed token. A server can verify its signature and claims without looking up a traditional session record for every request.
A JWT is not automatically encrypted. Anyone who obtains the token may be able to decode its claims, even though they cannot safely modify a correctly signed token.
JWT validation should include more than signature verification. Depending on the system, it may need to verify:
Revocation can be more difficult when long-lived tokens are accepted without a server-side check. Access tokens should therefore normally be short-lived, with refresh or session mechanisms handled separately.
For browser applications, an HttpOnly, Secure, appropriately configured cookie can reduce direct JavaScript access to session material. Cookie-based authentication requires CSRF protection. JavaScript-accessible storage avoids automatic cookie transmission but exposes tokens to any injected script running in the origin.
There is no storage choice that compensates for an application vulnerable to cross-site scripting. The architecture should minimize token exposure and use short lifetimes, rotation, secure cookie attributes, and server-side authorization.
Code Example
HTTP/1.1 200 OK
Set-Cookie: session=opaque-session-id;
Path=/;
Secure;
HttpOnly;
SameSite=Lax;
Max-Age=3600
// Simplified JWT claim checks:
function validateAccessToken(
token: VerifiedJwt
): void {
if (token.issuer !== EXPECTED_ISSUER) {
throw new AuthenticationError();
}
if (!token.audience.includes(API_AUDIENCE)) {
throw new AuthenticationError();
}
if (token.expiresAt <= Date.now()) {
throw new AuthenticationError();
}
}Common Interview Pitfalls
- Assuming a signed JWT is encrypted and safe for sensitive data.
- Validating only the token signature while ignoring issuer or audience.
- Issuing access tokens with unnecessarily long expiration periods.
- Storing long-lived tokens in JavaScript-accessible storage without considering XSS.
- Using cookie authentication without CSRF protections.
- Embedding rapidly changing permissions into tokens that remain valid for a long time.
- Logging complete session cookies or access tokens.
How do OAuth 2.0, OpenID Connect, the Authorization Code Flow, state, nonce, and PKCE work together?
Direct Answer
OAuth delegates API access, OpenID Connect adds authentication, and the code flow uses state, nonce, redirect validation, and PKCE to protect the exchange.
Detailed Explanation
OAuth 2.0 is an authorization framework that allows a client to obtain limited access to protected resources.
OpenID Connect, or OIDC, adds an authentication and identity layer on top of OAuth 2.0. It introduces an ID token containing claims about the authenticated end user.
A simplified Authorization Code Flow is:
1. The client creates a random state value.
2. It creates a PKCE code verifier and corresponding code challenge.
3. For OIDC, it also creates a random nonce.
4. The browser is redirected to the authorization endpoint.
5. The authorization server authenticates the user and obtains any required consent.
6. The browser returns to the client’s registered redirect URI with an authorization code and state.
7. The client verifies the returned state.
8. The client exchanges the authorization code and PKCE verifier at the token endpoint.
9. The client validates the returned ID token, including signature, issuer, audience, expiration, and nonce.
10. The access token is used only for its intended protected resource and scopes.
state binds the response to the initiating browser transaction and helps protect against authorization-response injection and CSRF-style attacks.
PKCE binds the authorization code to the client instance that initiated the flow. A stolen code cannot be exchanged without the original verifier.
The OIDC nonce binds the ID token to the authentication request and helps prevent replay.
Redirect URIs should be registered and matched precisely. Tokens should not be sent to arbitrary URLs supplied during login.
An ID token tells the client about an authentication event. It should not normally be used as an access token for an API.
Code Example
const codeVerifier =
createCryptographicallyRandomString();
const codeChallenge =
await createSha256Base64Url(codeVerifier);
const state =
createCryptographicallyRandomString();
const nonce =
createCryptographicallyRandomString();
const authorizationUrl = new URL(
authorizationEndpoint
);
authorizationUrl.searchParams.set(
'response_type',
'code'
);
authorizationUrl.searchParams.set(
'client_id',
clientId
);
authorizationUrl.searchParams.set(
'redirect_uri',
registeredRedirectUri
);
authorizationUrl.searchParams.set(
'scope',
'openid profile email'
);
authorizationUrl.searchParams.set(
'state',
state
);
authorizationUrl.searchParams.set(
'nonce',
nonce
);
authorizationUrl.searchParams.set(
'code_challenge',
codeChallenge
);
authorizationUrl.searchParams.set(
'code_challenge_method',
'S256'
);Common Interview Pitfalls
- Using OAuth terminology while treating the access token as proof of user authentication.
- Using an ID token as a general-purpose API access token.
- Failing to verify the state value when the browser returns.
- Using the authorization code flow without PKCE.
- Accepting loosely matched or attacker-controlled redirect URIs.
- Validating an ID token signature without checking issuer, audience, expiration, and nonce.
- Placing client secrets inside browser-delivered JavaScript.
- Requesting broader scopes than the application requires.
How should a full-stack application manage environment variables, secrets, and environment-specific configuration?
Direct Answer
Separate configuration from code, store secrets in managed secret systems, validate configuration at startup, restrict access, rotate credentials, and avoid exposing server values to clients.
Detailed Explanation
Application configuration varies across local development, testing, staging, and production. Examples include service endpoints, feature settings, resource identifiers, and operational limits.
A secret is configuration whose disclosure could grant access or create material risk. Examples include:
Good configuration practices include:
Frontend build systems may expose selected environment variables in browser bundles. Any value delivered to the browser must be treated as public, regardless of whether its name contains words such as secret or private.
Secrets should preferably be injected at runtime rather than copied into a container image. Build arguments, image layers, source maps, deployment manifests, and cached CI logs can accidentally preserve values.
The application should distinguish a missing configuration value from a valid empty value and should parse strings into validated types instead of relying on implicit conversions.
Code Example
type ServerConfiguration = {
databaseUrl: string;
sessionSecret: string;
nodeEnvironment:
| 'development'
| 'test'
| 'production';
requestTimeoutMs: number;
};
function loadConfiguration(
environment: NodeJS.ProcessEnv
): ServerConfiguration {
const databaseUrl = environment.DATABASE_URL;
const sessionSecret = environment.SESSION_SECRET;
const nodeEnvironment = environment.NODE_ENV;
const requestTimeoutMs = Number(
environment.REQUEST_TIMEOUT_MS
);
if (!databaseUrl) {
throw new Error('DATABASE_URL is required');
}
if (!sessionSecret || sessionSecret.length < 32) {
throw new Error(
'SESSION_SECRET must contain at least 32 characters'
);
}
if (
nodeEnvironment !== 'development' &&
nodeEnvironment !== 'test' &&
nodeEnvironment !== 'production'
) {
throw new Error('NODE_ENV is invalid');
}
if (
!Number.isInteger(requestTimeoutMs) ||
requestTimeoutMs <= 0
) {
throw new Error(
'REQUEST_TIMEOUT_MS must be positive'
);
}
return {
databaseUrl,
sessionSecret,
nodeEnvironment,
requestTimeoutMs
};
}Common Interview Pitfalls
- Committing production credentials to a source repository.
- Assuming an environment variable included in client code remains private.
- Baking credentials into a container image or reusable build artifact.
- Starting the application without validating required configuration.
- Giving every service access to the complete organization secret set.
- Logging environment objects that contain passwords or tokens.
- Using one permanent credential without a rotation procedure.
- Treating all environment variables as correctly typed strings.
How do containers, CI/CD checks, health probes, rolling deployments, and rollback work together?
Direct Answer
Containers package reproducible runtime artifacts, CI verifies them, readiness controls traffic, liveness detects stuck processes, rolling deployment limits disruption, and rollback restores a known version.
Detailed Explanation
A container image packages the application and its runtime dependencies into a versioned artifact. The same immutable image should normally move through testing and production rather than being rebuilt differently for each environment.
A continuous-integration pipeline may verify:
Containerized deployment platforms commonly distinguish several health signals:
A readiness failure should generally remove the instance from traffic without necessarily restarting it. A liveness failure can cause a restart. Making liveness depend on a temporary external dependency can create restart loops and cascading failures.
A rolling deployment gradually replaces old instances with the new version while maintaining available capacity. The rollout should proceed only when new instances become ready.
Rollback requires retaining a known-good artifact and ensuring database changes remain compatible. Reverting application code may not restore a destructively changed schema or lost data.
Deployment systems should expose version, commit, and image information so errors and metrics can be associated with the exact release.
Code Example
apiVersion: apps/v1
kind: Deployment
metadata:
name: resumeloop-api
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
spec:
containers:
- name: api
image: registry.example.com/resumeloop-api:sha-82a4
ports:
- containerPort: 3000
startupProbe:
httpGet:
path: /health/startup
port: 3000
failureThreshold: 30
periodSeconds: 2
readinessProbe:
httpGet:
path: /health/ready
port: 3000
periodSeconds: 5
livenessProbe:
httpGet:
path: /health/live
port: 3000
periodSeconds: 10Common Interview Pitfalls
- Rebuilding a different container image for each deployment environment.
- Using one identical endpoint and policy for every health probe without analysis.
- Making liveness fail whenever an optional external dependency is unavailable.
- Sending traffic to a new instance before startup and readiness checks succeed.
- Deploying a destructive schema migration that prevents application rollback.
- Using mutable image tags without recording the deployed digest or commit.
- Allowing multiple production deployments to race without concurrency controls.
- Assuming restarting an unhealthy process fixes every underlying failure.
How would you design a secure, reliable production deployment pipeline for a full-stack application?
Direct Answer
Build one traceable artifact, enforce quality and security gates, use short-lived credentials and protected environments, deploy progressively, validate health, and preserve rollback compatibility.
Detailed Explanation
A production deployment pipeline should protect source integrity, build integrity, infrastructure access, application availability, data compatibility, and recovery.
A practical pipeline includes the following stages.
Source and pull-request controls
Build stage
Credential and environment controls
GitHub deployment environments can apply protection rules and withhold environment secrets until those rules pass.
Database evolution
Deployment strategy
Rollback and response
Auditability
Record who approved the release, what artifact was deployed, which migrations ran, what configuration version was used, and whether post-deployment checks passed.
A successful pipeline is not only one that deploys quickly. It must prevent unauthorized releases, limit blast radius, and make recovery predictable.
Code Example
name: Production deployment
on:
workflow_dispatch:
concurrency:
group: production
cancel-in-progress: false
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<pinned-commit>
- name: Install dependencies
run: npm ci
- name: Verify application
run: |
npm run lint
npx tsc --noEmit
npm test -- --runInBand
npm run build
- name: Build immutable image
run: |
docker build --tag registry.example.com/app:${GITHUB_SHA} .
deploy:
needs: verify
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- name: Obtain short-lived cloud credentials
run: ./scripts/authenticate-with-oidc.sh
- name: Deploy verified image
run: |
./scripts/deploy.sh registry.example.com/app:${GITHUB_SHA}
- name: Run production smoke checks
run: ./scripts/smoke-test.shCommon Interview Pitfalls
- Using permanent production cloud credentials in CI secrets.
- Rebuilding application code after approval instead of deploying the verified artifact.
- Allowing production secrets to be read before deployment protection rules pass.
- Running destructive migrations that make rollback impossible.
- Deploying globally without readiness checks or staged exposure.
- Using unpinned third-party pipeline actions with production permissions.
- Allowing overlapping production deployments to interfere with one another.
- Collecting deployment logs without recording the artifact digest and source commit.
- Considering a deployment successful before post-release health checks complete.
How do a monolith, modular monolith, and microservices architecture differ?
Direct Answer
A monolith deploys as one application, a modular monolith preserves internal domain boundaries, and microservices split capabilities into independently deployed networked services.
Detailed Explanation
These architectural styles differ primarily in deployment boundaries, ownership, communication, and operational complexity.
Monolith
A poorly structured monolith can become tightly coupled, but being monolithic does not automatically mean the code must be disorganized.
Modular monolith
This approach preserves simpler operations while creating boundaries that may support future extraction.
Microservices
Microservices introduce costs such as distributed tracing, service discovery, network failures, message delivery, data consistency, contract compatibility, security between services, and more complex local development.
The decision should respond to real needs such as independent team ownership, different scaling profiles, regulatory isolation, or separate release schedules. Microservices should not be selected only because the product may grow in the future.
Code Example
// A modular-monolith boundary.
export interface ApplicationsModule {
createApplication(
command: CreateApplicationCommand
): Promise<ApplicationSummary>;
getApplication(
userId: string,
applicationId: string
): Promise<ApplicationSummary | null>;
}
// Other modules depend on this public contract.
// They do not import repositories or database
// entities from the applications module.Common Interview Pitfalls
- Assuming every monolith is automatically unmaintainable.
- Calling an application modular while allowing unrestricted cross-module imports.
- Splitting services before clear domain or ownership boundaries exist.
- Allowing several microservices to modify the same database tables directly.
- Ignoring network and operational failure modes when introducing services.
- Creating microservices that require coordinated deployment for every change.
How do unit, integration, contract, and end-to-end tests contribute to a full-stack testing strategy?
Direct Answer
Unit tests isolate logic, integration tests verify real collaborations, contract tests protect service interfaces, and end-to-end tests validate critical user journeys.
Detailed Explanation
Different test levels protect different risks.
Unit tests
Integration tests
Contract tests
End-to-end tests
A balanced strategy places a test at the lowest level that can provide meaningful confidence. A pure formatting function does not need an end-to-end test, while a signup or payment workflow should not rely only on mocked unit tests.
Tests should verify observable behavior and contracts rather than internal method calls. Critical persistence behavior should be tested against the actual database engine because mocks cannot reproduce constraints, isolation, or SQL execution.
Code Example
import { test, expect } from '@playwright/test';
test('user can save a discovered job', async ({
page
}) => {
await page.goto('/jobs');
await page
.getByRole('link', {
name: 'Frontend Developer at Example'
})
.click();
await page
.getByRole('button', {
name: 'Save job'
})
.click();
await expect(
page.getByText('Job saved')
).toBeVisible();
await page.goto('/applications');
await expect(
page.getByRole('heading', {
name: 'Frontend Developer'
})
).toBeVisible();
});Common Interview Pitfalls
- Testing every behavior only through slow end-to-end tests.
- Mocking the database in tests intended to verify transactions or constraints.
- Testing implementation methods instead of externally observable behavior.
- Allowing independently deployed services to change contracts without consumer verification.
- Using code coverage as the only measure of test quality.
- Protecting critical user journeys only with isolated unit tests.
- Sharing mutable test data that makes test order affect results.
How should a full-stack system use browser, CDN, application, and database caching without serving incorrect data?
Direct Answer
Cache only where ownership and freshness are defined, choose stable keys and bounded lifetimes, invalidate on relevant writes, and protect private or user-specific responses.
Detailed Explanation
Caching can exist at several layers:
Each layer has different ownership and invalidation behavior.
A common application strategy is cache-aside:
1. Read from the cache using a stable key.
2. On a hit, return the cached value.
3. On a miss, load from the authoritative store.
4. Store the result with a bounded lifetime.
5. On a relevant write, delete or refresh the affected entry.
Important design decisions include:
A time-to-live limits staleness but does not guarantee freshness. Explicit invalidation reduces stale time but can miss relationships unless dependencies are understood.
A cache stampede can occur when many requests miss the same popular key and all recompute it simultaneously. Mitigations include request coalescing, locks, early refresh, randomized expiration, or stale-while-revalidate behavior.
Caches should generally be treated as replaceable acceleration layers rather than the only copy of essential data. Personalized data must never be placed in a shared cache without a safe identity or tenant-aware cache key.
Code Example
async function getJob(
jobId: string
): Promise<Job> {
const cacheKey = `job:v2:${jobId}`;
const cached =
await cache.get(cacheKey);
if (cached) {
return parseJob(
JSON.parse(cached) as unknown
);
}
const job =
await jobRepository.getRequired(jobId);
await cache.set(
cacheKey,
JSON.stringify(job),
{
expiresInSeconds: 300
}
);
return job;
}
async function updateJob(
command: UpdateJobCommand
): Promise<Job> {
const updated =
await jobRepository.update(command);
await cache.delete(
`job:v2:${command.jobId}`
);
return updated;
}Common Interview Pitfalls
- Caching personalized data under a key shared by every user.
- Adding a cache without defining acceptable staleness.
- Using query parameters incompletely when building a cache key.
- Treating a time-to-live as proof that cached data is always correct.
- Allowing many simultaneous cache misses to overload the database.
- Making the application completely unavailable when an optional cache fails.
- Keeping cached entries after related mutations without invalidation.
When should a full-stack application use a queue or background job, and how should it handle delivery and eventual consistency?
Direct Answer
Use asynchronous processing for slow or decoupled work, acknowledge only after successful processing, make consumers idempotent, bound retries, and expose pending or failed states.
Detailed Explanation
A queue separates the producer of work from the process that performs it.
Background processing is useful for:
A request can commit the primary business state, enqueue work, and return a pending status without making the user wait for the entire operation.
Message systems commonly provide at-least-once delivery when acknowledgements and retries are used. A worker may therefore receive the same message more than once. Consumers must be idempotent or use deduplication and database constraints to prevent repeated effects.
A worker should acknowledge a message only after the required durable work has succeeded. Acknowledging before persistence risks message loss if the worker crashes.
Retry behavior should distinguish transient failures from permanent failures. Retries should be bounded and delayed. Messages that repeatedly fail can be moved to a dead-letter queue for inspection or controlled replay.
Queue processing creates eventual consistency. The initial transaction may be complete while secondary effects remain pending. The API and frontend should expose states such as queued, processing, completed, and failed when users need visibility.
Publishing a message after committing a database transaction can fail, leaving data without its event. The transactional outbox pattern stores the intended event in the same database transaction, then a separate publisher sends it reliably.
Code Example
await database.transaction(async (transaction) => {
const analysis =
await transaction.resumeAnalyses.insert({
id: analysisId,
userId,
status: 'queued'
});
await transaction.outbox.insert({
id: createId(),
eventType: 'resume-analysis-requested',
aggregateId: analysis.id,
payload: {
analysisId: analysis.id,
userId
}
});
});
// A publisher later sends unsent outbox events.
// The worker uses analysisId as an idempotency key.Common Interview Pitfalls
- Assuming a queued message will be delivered exactly once.
- Acknowledging a message before durable processing succeeds.
- Retrying permanent validation failures indefinitely.
- Performing a non-idempotent side effect on every redelivery.
- Publishing an event after a database commit without handling publication failure.
- Hiding long-running job status from users who need progress or recovery.
- Allowing poison messages to block normal processing indefinitely.
How should traces, metrics, logs, and profiles be used to diagnose a full-stack performance problem?
Direct Answer
Metrics reveal the scope, traces locate latency across components, logs explain individual events, and profiles identify expensive code or resource consumption.
Detailed Explanation
Observability helps engineers understand system behavior through emitted telemetry.
The major signals include:
A practical investigation might proceed as follows:
1. Confirm the user-visible symptom and affected route or workflow.
2. Use metrics to determine when it began, its frequency, and affected versions or users.
3. Inspect a slow distributed trace to locate the dominant span.
4. Correlate logs through trace and span identifiers.
5. Review database execution plans, dependency latency, queue delay, or browser main-thread work as appropriate.
6. Use a profile when the delay is caused by code execution or resource consumption.
7. Apply one focused change and compare the same telemetry afterward.
Telemetry should include release version, environment, route, operation, dependency, and safe tenant or cohort context where appropriate.
High-cardinality or sensitive values should not be added carelessly to metric labels. Email addresses, tokens, complete URLs with user data, and raw request bodies can create privacy, cost, and security risks.
Alerts should be tied to user impact and actionable service-level indicators rather than every temporary infrastructure fluctuation.
Code Example
const span = tracer.startSpan(
'applications.create',
{
attributes: {
'app.operation': 'create_application',
'deployment.version': releaseVersion
}
}
);
try {
const startedAt = performance.now();
const result =
await applicationService.create(command);
requestDuration.record(
performance.now() - startedAt,
{
operation: 'create_application',
outcome: 'success'
}
);
span.setStatus({ code: SpanStatusCode.OK });
return result;
} catch (error: unknown) {
span.recordException(
normalizeError(error)
);
span.setStatus({
code: SpanStatusCode.ERROR
});
throw error;
} finally {
span.end();
}Common Interview Pitfalls
- Collecting logs without correlation or trace identifiers.
- Using averages that hide severe high-percentile latency.
- Adding user emails or unbounded values as metric labels.
- Alerting on infrastructure changes that have no user impact.
- Recording telemetry without the deployed release version.
- Adding traces without instrumenting important database and dependency calls.
- Attempting performance optimization before identifying the dominant bottleneck.
How would you design a scalable, reliable full-stack system for job discovery, application tracking, and asynchronous AI processing?
Direct Answer
Define domain and consistency boundaries, keep services stateless where useful, protect the database, queue slow work, cache measured hotspots, instrument journeys, and scale from observed demand.
Detailed Explanation
A production system should begin with product requirements and measurable service objectives rather than infrastructure choices.
Core requirements
Define expected traffic, data volume, latency, availability, privacy, retention, consistency, and recovery needs for:
Architecture boundary
A modular application can initially contain clear modules for identity, jobs, applications, resumes, AI credits, and analysis workflows. Extracting a service is justified when independent ownership, scaling, security, or deployment pressure becomes substantial.
Request path
Persistence
Asynchronous work
AI processing, email, document conversion, and bulk imports can run through queues. Store job state, use idempotent workers, apply bounded retries, and place exhausted failures into controlled recovery workflows.
Credit consumption and the analysis request should be coordinated transactionally so concurrent requests cannot bypass limits.
Caching
Cache public job pages, repeated safe queries, or expensive derived data only after defining keys, freshness, privacy, invalidation, and cache-failure behavior.
Reliability
Scaling
Scale application workers horizontally when they are stateless. Queue consumers can scale according to queue depth, message age, and processing rate. Database scaling should follow evidence and may involve query optimization, indexes, replicas for eligible reads, partitioning, or workload separation.
Kubernetes horizontal autoscaling can adjust replica count based on resource or custom metrics, but scaling should use signals related to actual workload and should account for startup and readiness behavior.
Observability and testing
Instrument critical journeys from browser interaction through API, database, queue, and worker. Track latency, errors, retries, queue age, credit conflicts, and release version. Test domain rules, database behavior, contracts, and critical end-to-end workflows.
The design should remain proportional. Complexity is justified only when it solves a measured reliability, ownership, performance, or delivery problem.
Code Example
type AnalysisState =
| {
status: 'queued';
analysisId: string;
}
| {
status: 'processing';
analysisId: string;
startedAt: string;
}
| {
status: 'completed';
analysisId: string;
resultId: string;
}
| {
status: 'failed';
analysisId: string;
retryable: boolean;
};
async function requestAnalysis(
command: RequestAnalysisCommand
): Promise<AnalysisState> {
return database.transaction(
async (transaction) => {
await transaction.creditBalances
.consumeOneCredit({
userId: command.userId
});
const analysis =
await transaction.analyses.insert({
id: createId(),
userId: command.userId,
resumeId: command.resumeId,
status: 'queued'
});
await transaction.outbox.insert({
id: createId(),
eventType: 'analysis-requested',
aggregateId: analysis.id,
payload: {
analysisId: analysis.id
}
});
return {
status: 'queued',
analysisId: analysis.id
};
}
);
}Common Interview Pitfalls
- Starting with microservices before proving independent scaling or ownership needs.
- Allowing concurrent AI requests to bypass credit or usage limits.
- Running slow document or AI processing inside interactive request transactions.
- Scaling application replicas while ignoring the database connection limit.
- Caching private resume or application data under unsafe shared keys.
- Using CPU as the only scaling signal for queue-driven workers.
- Treating optional recommendation failure as a total product outage.
- Deploying destructive schema changes that prevent rollback.
- Collecting infrastructure metrics without measuring critical user journeys.
Want to tailer your resume for Full-Stack Developer roles?
Import your resume, scan it for critical Full-Stack Developer keywords, and compare it against ATS standards instantly.