AttemptIQ: System Design Interview Preparation
You built this product. This book prepares you to talk about it at the level that earns a hire decision — not "I used Redis because it's fast," but "here's exactly why Redis is fast for this use case, what it can't do, and what I gave up by choosing it."
Every answer is grounded in the actual AttemptIQ codebase. Every code snippet is real.
How to use this book
Read each answer until you can say it out loud naturally — not recite it, say it. The answers are written to be internalized, not memorized. If an answer sounds like a textbook, it will land like a textbook. If it sounds like someone who built and thought hard about the system, it lands like engineering depth.
The 4 question layers, in order of what wins interviews:
- Layer 1 (WHAT): Warmup. Answer fast, then pivot to Why before the interviewer asks.
- Layer 2 (WHY): The real question. Reason from first principles, not from "best practices."
- Layer 3 (HOW DEEP): Mechanics. Know what actually happens under the hood.
- Layer 4 (STRESS): Where interviews are won or lost. Think out loud, diagnose systematically.
1. System Overview
Q: Describe AttemptIQ's architecture end to end.
Answer: AttemptIQ is a Next.js 14 monolith running on a single Hetzner VPS — 4 vCPU, 8GB RAM, 80GB NVMe. All business logic lives in the server, served by PM2 in cluster mode (4 workers). Behind PM2 sits pgBouncer (connection pool) → PostgreSQL (source of truth) and Redis (cache + job queues). Cloudflare sits in front of everything for DDoS protection, CDN, and TLS termination.
Why:
A monolith was the right call at this scale. Microservices add distributed systems problems — network partitions, partial failures, distributed tracing overhead — before you have the traffic to justify them. A single well-structured Next.js app with clear service/repository layers (all business logic in services/, all DB queries in repositories/) gives you most of the organizational benefits of microservices without the operational cost.
In AttemptIQ:
Browser → Cloudflare (CDN/DDoS) → Nginx (TLS, rate limiting, proxy) →
PM2 cluster (4 Next.js workers, port 3000) →
pgBouncer (port 6432) → PostgreSQL (port 5432)
↘
Redis (port 6379) ← BullMQ workers (1 process)
The tradeoff: A monolith means you can't scale the analytics service independently from the test delivery service. If analytics queries hammer the DB, they can starve test sessions. We solved this with materialized views — analytics reads pre-aggregated data, not live tables.
Anticipating the follow-up: You might ask why Next.js App Router specifically. App Router gives us server-side rendering at the component level, not just the page level. Public exam prep content (questions, syllabus, SEO pages) is rendered on the server and cached at Cloudflare — zero JavaScript hydration cost for crawlers and cold visitors. Authenticated dashboard routes are dynamic. We get both from one framework.
Q: What were the hardest technical decisions?
Answer: Three decisions stand out: the session creation race condition, the payment webhook idempotency, and choosing pgBouncer transaction pooling.
Why these are hard: Each one has a simple naive implementation that works fine in development and fails silently in production under concurrent load.
Session creation: the naive approach is SELECT COUNT then INSERT. Under concurrent requests from the same user (double-click, network retry), both requests see count=1, both pass the check, both insert — free user gets 3 sessions instead of 2. The correct solution embeds the COUNT check inside the INSERT's WHERE clause and wraps in an advisory lock.
Payment webhook: the gateway retries on timeout. Two deliveries arrive simultaneously. Without proper idempotency, the user gets double-charged or their plan upgraded twice. The correct solution is a CTE that atomically inserts the event record and reads the existing state in one statement — no window between check and act.
pgBouncer transaction pooling: transaction pooling means the underlying PostgreSQL connection changes between transactions. Session-level advisory locks are silently dropped. You discover this in production when two concurrent requests both create sessions despite having "advisory locks" — because the locks were on different connections.
The tradeoff: These decisions add code complexity. The session creation alone is 30 lines of SQL instead of 5. The complexity is justified because the failure mode is silent data corruption, not a visible error.
Q: What would you do differently today?
Answer: Two things: observability earlier, and stricter TypeScript discipline from day one.
Observability: I added Sentry and the cron_health table mid-build. The first few weeks, silent failures in background jobs (analytics refresh timing out) were invisible. I'd instrument the jobs and health check endpoint from day one.
TypeScript: the repository layer types are solid. But some early API routes used as unknown as Record<string, unknown> casts to work around type mismatches. These are technical debt — the cast hides the actual shape and means TypeScript stops protecting you. I'd write the Zod schemas first, derive the types from Zod, and never cast.
Anticipating the follow-up: You might ask if I'd choose a different stack. No — Next.js + PostgreSQL + Redis is the right stack for an exam prep SaaS. The bottleneck at this scale is the engineering team's time, not the framework's performance. A more complex stack (separate API server, separate frontend) would have doubled the deployment and debugging overhead without measurable benefit.
Q: How does a request flow from browser to database and back?
Answer: Let's trace a user answering a practice question — the most frequent and latency-sensitive path.
- Browser → Cloudflare: User clicks answer. Client sends
POST /api/attempts/submitwith session cookie. - Cloudflare → Nginx: Cloudflare terminates TLS (HTTPS). Passes request to the VPS on port 443. Nginx rate-limits (120 req/min zone), adds
X-Real-IPheader, proxies tolocalhost:3000. - Nginx → PM2 worker: PM2 runs 4 Next.js workers in cluster mode. Nginx's proxy_pass distributes requests round-robin (actually kernel load balancing on the same port).
- Next.js route handler (
app/api/attempts/submit/route.ts): Reads session cookie, calls Better Auth to validate (DB lookup onba_sessions). Zod validates the request body. CallsattemptsService.submitAttempt(). - Service layer (
services/attempts.service.ts): Validates session ownership, checks time limit (server clock), checks if question already answered, fetchescorrect_answerserver-side, computesisCorrect, persists attempt. - Repository layer → pgBouncer → PostgreSQL: The INSERT for the attempt goes through
pg.Pool→ pgBouncer (port 6432) → one of 25 real PostgreSQL backend processes. PostgreSQL writes to WAL, modifies heap, returns. - Response: Service returns
{ isCorrect, correctAnswer, explanation }. Route handler returns JSON. Nginx sends back to Cloudflare. Cloudflare sends to browser.
Total: typically 80-150ms. The DB query is 5-20ms. Network + Cloudflare adds 20-50ms depending on user's location. Next.js handler overhead is 10-30ms.
The tradeoff: The session validation (Better Auth lookup) adds one DB query to every authenticated request. A JWT approach would skip this. We chose sessions because JWTs cannot be revoked — if a user changes their password or we need to force logout, a session delete is instant. A JWT lives until expiry.
2. Database Design
Q: Walk me through your schema design decisions.
Answer:
The most non-obvious schema decisions are: question_ids stored as a PostgreSQL array in the sessions table, session_type denormalized into user_attempts, and the ba_ prefix on auth tables.
Why question_ids as an array:
We needed to know which questions belong to a session, in what order, without joining to a junction table on every request. A session_questions junction table would require a JOIN on every "get next question" call. The array stores the question IDs at session creation and never changes — it's essentially a snapshot. The session's question order is deterministic and queryable without a JOIN.
Why session_type denormalized into user_attempts:
The materialized views aggregate attempts by session type (practice vs mock test). Without denormalization, every MV refresh query would need JOIN sessions ON sessions.id = user_attempts.session_id just to get the session type. That's a join over millions of rows on every 2-hour refresh. Denormalizing session_type into each attempt row makes the MV queries fast — one scan of user_attempts, no joins.
The ba_ prefix:
Better Auth uses its own tables (ba_users, ba_sessions, ba_accounts). Prefixing avoids collision with our application tables. We have our own user_profiles table (plan tier, preferences) that references ba_users.id via FK. The auth tables are owned by Better Auth's migrations; our tables are owned by our migrations. Mixing them would be fragile.
The tradeoff:
Denormalizing session_type means if we ever change a session's type after creation (we don't), we'd need to update all associated attempts. For our use case, session type is immutable. The performance gain on MV refreshes outweighs the theoretical denormalization risk.
Anticipating the follow-up:
You might ask about the ba_users.id type. It's TEXT (UUID as string), not a PostgreSQL UUID type. This is Better Auth's choice. Our application FKs reference it as TEXT. The practical difference is minimal — PostgreSQL can index TEXT the same way — but it's worth knowing because joining on TEXT vs UUID requires care about implicit casts.
Q: Why did you choose this indexing strategy?
Answer: Every index in AttemptIQ answers a specific query. No speculative indexes.
The critical ones: user_id on user_attempts (every analytics query and MV refresh filters by user), a unique index on mv_user_subject_stats(user_id, subject) (required for REFRESH CONCURRENTLY), and (user_id, session_id) on user_attempts for session-scoped attempt lookups.
Why REFRESH CONCURRENTLY needs a unique index:
REFRESH MATERIALIZED VIEW CONCURRENTLY works by computing the full new result in a temp table, then diffing against the current MV. The diff needs a way to identify matching rows — that's the unique index. Without it, PostgreSQL can't diff; it falls back to plain REFRESH which locks the MV for reads.
The cost of any index: every INSERT and UPDATE to user_attempts must also update the index. For a write-heavy table like user_attempts, each index adds write overhead. We keep indexes minimal — only those that serve frequent, slow queries.
Anticipating the follow-up:
You might ask about partial indexes. The sessions table has many submitted sessions (historical) and few active ones. A partial index WHERE is_submitted = false on sessions would be smaller and faster for queries that only look at active sessions. We haven't added it yet — it's a valid optimization for when session count grows.
Q: Where do you draw transaction boundaries?
Answer: Three places: session creation, payment processing, and advisory lock operations.
Session creation wraps in a transaction because the advisory lock (pg_advisory_xact_lock) must be transaction-scoped — it's automatically released at COMMIT or ROLLBACK. Without the transaction, the lock has no defined lifetime.
Payment processing wraps all three operations (capture payment, upgrade plan, mark webhook processed) in one transaction. If any step fails, all roll back. This guarantees the user's plan state is always consistent with the payment record.
// services/payments.service.ts
await client.query('BEGIN');
// Step 1: capture payment (WHERE status='pending' RETURNING id)
// Step 2: upgrade user plan
// Step 3: mark webhook processed
await client.query('COMMIT');
The tradeoff:
Longer transactions hold database locks longer. In the payment case, the transaction touches payments, user_profiles, and webhook_events — three tables. Other requests touching the same rows (the same payment, the same user's profile) block until COMMIT. This is acceptable because payment processing is rare (once per purchase) and fast (three simple UPDATEs).
Anticipating the follow-up:
You might ask about isolation levels. We use PostgreSQL's default: READ COMMITTED. Each statement sees data committed before that statement began — not before the transaction began. This means two statements in the same transaction can see different snapshots. For our use cases, this is fine — the advisory lock serializes concurrent session creation, and the WHERE status='pending' RETURNING id pattern handles concurrent payment processing.
Q: Did you have N+1 problems? How did you find them?
Answer:
Yes — in getFullSessionResult, which loads the complete results for a practice session after submission.
The naive implementation would loop over session.question_ids, fetch each question, fetch each answer. For a 100-question session: 202 DB queries. Found it by reading the code during review, not in production — the structure of the loop was obviously wrong.
The fix:
// services/sessions.service.ts
const [attempts, publicQuestions, questionsWithAnswers] = await Promise.all([
this.attemptsRepo.listBySession(sessionId),
this.questionsRepo.findAllByIds(questionIds),
this.questionsRepo.findAllByIdsWithAnswers(questionIds),
]);
const attemptMap = new Map(attempts.map(a => [a.question_id, a]));
const publicMap = new Map(publicQuestions.map(q => [q.id, q]));
const answerMap = new Map(questionsWithAnswers.map(q => [q.id, q]));
Three queries in parallel. Maps for O(1) lookup. The result loop does zero DB queries — only Map.get() calls.
Why Maps specifically:
.find() inside a loop is O(N²). For 100 questions, that's 10,000 comparisons. A Map lookup is O(1) — hash table. For 100 questions: 300 Map.get() calls. At 1,000 questions, the difference is dramatic: 1,000,000 comparisons vs 3,000.
Anticipating the follow-up: You might ask how to detect N+1 in production. Log query count per request. If one endpoint consistently makes 50+ queries, investigate. Tools like pg-stat-statements in PostgreSQL or DataDog APM show per-request query count. We don't have these yet — finding N+1 via code review is sufficient at current scale.
Q: What happens if pgBouncer is misconfigured?
Answer:
The most dangerous misconfiguration is pool_mode = session when the app expects transaction. In session mode, pgBouncer assigns a real PG connection for the lifetime of the app's connection (not per transaction). This means 40 app connections map to 40 real PG connections — defeating the multiplexing entirely. Symptoms: connection count grows, PostgreSQL slows down, eventually hits max_connections.
The second dangerous misconfiguration is max_prepared_statements > 0 with transaction pooling. Prepared statements are connection-scoped in PostgreSQL. Transaction pooling reuses connections across app requests, so a prepared statement created on connection A might not exist when the next request lands on connection B. The symptom: "prepared statement does not exist" errors, randomly, in production.
# pgbouncer.ini — critical for transaction mode
max_prepared_statements = 0
pool_mode = transaction
Anticipating the follow-up:
You might ask how I'd catch these in staging. Run SHOW POOLS in psql against pgBouncer (psql -p 6432) — it shows active, idle, and waiting connections per pool. If active connections = max_pool_size during normal load, you're hitting the ceiling. Load test (k6) with the staging config; watch pgBouncer stats during the test.
3. Test Delivery Engine
Q: How does a test session begin, persist, and end?
Answer: Session creation is the most security-critical path in the product. Here's the full flow:
- Client calls
POST /api/sessionswith{ subject, questionCount, difficulty }. - Route handler authenticates, reads
plan_tierfromuser_profiles, callssessionsService.createPracticeSession(). - Service calls
sessionsRepo.createPracticeSession(). - Repo acquires per-user advisory lock, then runs one atomic INSERT:
WITH selected AS (
SELECT ARRAY_AGG(q.id) AS qids
FROM (SELECT q.id FROM questions q
WHERE q.subject = $2 AND q.is_approved = true
ORDER BY RANDOM() LIMIT $3) q
)
INSERT INTO sessions (user_id, session_type, question_ids)
SELECT $1, 'practice', s.qids
FROM selected s
WHERE s.qids IS NOT NULL
AND array_length(s.qids, 1) > 0
AND (SELECT COUNT(*) FROM sessions WHERE user_id = $1 AND session_type = 'practice') < 2
RETURNING id
- If the COUNT guard fails (limit reached), INSERT inserts nothing, RETURNING returns empty, service throws
SessionLimitReachedError. - Session is now in DB.
question_idsarray is set, never changes.
For each question: client calls GET /api/sessions/:id/next-question → service returns next unanswered question from question_ids order.
For answers: client calls POST /api/attempts/submit. Server validates ownership, checks time limit, fetches correct_answer server-side, persists attempt.
Session ends: client calls POST /api/sessions/:id/submit. Server sets is_submitted = true, ended_at = now().
The tradeoff:
ORDER BY RANDOM() for question selection is slow on large question tables (full sequential scan). For 10,000 questions, it's fast enough. At 100,000 questions, we'd need a smarter selection strategy (e.g., random sampling with TABLESAMPLE).
Q: What happens if a user loses connectivity mid-test?
Answer: Nothing is lost. The session state lives in PostgreSQL — it's not in-memory or in Redis. When connectivity restores:
- Client resumes — calls
GET /api/sessions/:id/stateto get current answered count. - Client calls
GET /api/sessions/:id/next-questionto get the next unanswered question. - Already-answered questions are in
user_attempts. The server knows exactly where the user was.
The question_ids array in the session determines question order. The server tracks which IDs have attempts via user_attempts.session_id. The "next question" logic excludes already-answered IDs.
Anticipating the follow-up:
You might ask what happens if the client already knew the answer (had fetched the question) but lost connectivity before submitting. The answer: the client must re-submit when connectivity restores. The question is fetched again (it's the same question, same order). The attempt insert is idempotent — user_attempts has a unique constraint on (user_id, question_id, session_id). A duplicate submit gets a unique violation → AnswerAlreadyLockedError → client handles gracefully.
Q: What prevents a user from submitting an answer twice?
Answer: Two layers: application-level check and database-level unique constraint.
Application layer:
// services/attempts.service.ts
const alreadyAnswered = await this.attemptsRepo.existsForQuestion(dto.sessionId, dto.questionId);
if (alreadyAnswered) throw new AnswerAlreadyLockedError();
Database layer:
-- user_attempts has UNIQUE(user_id, question_id, session_id)
-- Even if two concurrent requests bypass the app check, only one INSERT succeeds
The database constraint is the true guard. The app-level check is an early exit that avoids the unnecessary INSERT attempt.
The tradeoff:
The app check requires one extra SELECT before each INSERT. This is worth it: it provides a typed error (AnswerAlreadyLockedError) that the client can handle gracefully, vs a raw database error that requires parsing.
Q: How is time enforced — client or server, and why?
Answer: Server. Always server. The client clock can be manipulated.
// services/attempts.service.ts
if (session.time_limit_seconds) {
const elapsedMs = Date.now() - new Date(session.started_at).getTime();
const elapsedSeconds = Math.floor(elapsedMs / 1000);
if (elapsedSeconds > session.time_limit_seconds + 10) {
throw new SessionExpiredError();
}
}
session.started_at is set at INSERT time (PostgreSQL now()). It never changes — the column is not exposed in any UPDATE. Date.now() on the server is the VPS system clock. The client's clock is display-only: it counts down visually but the server doesn't trust it.
The +10 second grace is for network lag. A user submitting at exactly the deadline has their request in-flight for 1-3 seconds. Rejecting it would be unfair.
Anticipating the follow-up: You might ask what happens if the VPS clock drifts. NTP (Network Time Protocol) runs on the VPS and syncs the system clock to internet time servers. Clock drift on a well-configured Linux server is <100ms. At that scale, the 10-second grace window completely covers it.
Q: What happens if two browser tabs are open for the same session?
Answer: The second tab can answer questions, but the session still ends correctly. Here's why it doesn't cause data corruption:
- Both tabs share the same session ID.
- Both tabs call
GET /api/sessions/:id/next-question— they get the same "next unanswered" question (both see the same state). - One tab answers question A →
user_attemptshas an entry for(user_id, question_A, session_id). - The other tab tries to answer question A → app check:
existsForQuestionreturns true →AnswerAlreadyLockedError. The second tab's answer is rejected. - Both tabs can continue answering different questions (questions that haven't been answered yet).
We also track tab_switch_count — each time the tab loses focus (potential cheating signal), we increment it:
UPDATE sessions
SET tab_switch_count = tab_switch_count + 1
WHERE id = $1 AND user_id = $2 AND is_submitted = false
RETURNING tab_switch_count
This is a soft signal for analytics (identifying exam integrity patterns), not a hard block.
The tradeoff:
We could enforce single-tab by using a session-level lock in Redis (set a key active_tab:{session_id} with a short TTL, refresh it via heartbeat). This would block the second tab explicitly. We chose not to — the unique constraint handles the data integrity concern, and blocking legitimate use cases (user's browser crashed, they re-opened the tab) would be worse UX than allowing two tabs.
4. Authentication and Sessions
Q: How does auth work end to end?
Answer: Better Auth handles the mechanics. On login:
- Client sends email + password to
POST /api/auth/sign-in/email. - Better Auth hashes the password (bcrypt), compares to stored hash in
ba_users. - On success: generates a cryptographically random session token, stores
{token, user_id, expires_at}inba_sessions. - Sets
Set-Cookie: ba.session_token=<token>; HttpOnly; Secure; SameSite=Lax.
On every subsequent authenticated request:
- Browser sends the cookie automatically.
- Middleware reads cookie, checks presence. No DB lookup in middleware (Edge Runtime, no PostgreSQL).
- Route handler or server component calls Better Auth to validate — DB lookup on
ba_sessions. - Returns user object.
In AttemptIQ: The middleware does a fast cookie-presence check (Edge-compatible, sub-millisecond). The real validation happens in the server, where we have PostgreSQL access. This two-layer approach avoids hitting the DB for requests that have no cookie at all (fast redirect to login).
The tradeoff:
One DB lookup per authenticated request. At 1,000 authenticated requests/second, that's 1,000 ba_sessions lookups/second. With pgBouncer pooling and ba_sessions likely hot in PostgreSQL's shared_buffers, this is fast (1-2ms per lookup). At 10,000 req/sec, we'd add a Redis session cache layer.
Q: What happens when a session expires mid-test?
Answer:
Session expiry is handled by Better Auth (it sets expires_at on the session row). If the session expires while a test is in progress:
- The client still has the cookie. The cookie has its own expiry (separate from the session row's
expires_at). - When the client calls
POST /api/attempts/submit, Better Auth validates the session. If expired, returns 401. - The client receives a 401 — the session is ongoing in the DB (
is_submitted = false), but the user can't submit more answers.
In practice, we set session TTL to 7 days. Mock tests take at most 3 hours. There's no realistic scenario where a session expires mid-test unless the user leaves for more than 7 days and comes back. That's an edge case we handle by showing a re-login prompt.
Anticipating the follow-up:
You might ask about the test session vs the auth session — these are two different things. The auth session (in ba_sessions) is "is this user logged in." The test session (in our sessions table) is "is this exam in progress." They have independent lifecycles. Even if the auth session expires, the test session row is still in the DB — a re-login would allow the user to resume.
Q: How do you prevent session hijacking?
Answer: Three mechanisms: HttpOnly cookies, Secure flag, and concurrent session limits.
HttpOnly means JavaScript cannot read the cookie (document.cookie doesn't include it). An XSS attack that injects malicious JavaScript cannot steal the session token. The token is only sent by the browser over HTTP — it's invisible to client-side code.
Secure means the cookie is only sent over HTTPS. An attacker intercepting HTTP traffic (on an open WiFi network) cannot see the cookie.
SameSite=Lax means the cookie is not sent on cross-site POST requests. A malicious site cannot submit a form to AttemptIQ using the victim's cookies.
Concurrent session limit (max 2):
// lib/auth/index.ts — before hook on session create
// If user already has 2 sessions, revoke the oldest before creating the new one
If a session token is stolen and used on an attacker's machine, that creates a third session, which revokes the oldest legitimate session. The legitimate user gets logged out — a signal that something is wrong.
The tradeoff: HttpOnly means we can't implement JavaScript-based "logout all devices" — we can't read the cookie to send it to a logout endpoint. But we don't need to: the server-side session revocation via Better Auth works regardless of what the client does.
5. Background Jobs and Queues
Q: What work is async and why?
Answer: Six categories: analytics MV refresh, revision list generation, scheduled reviews, email notifications, payment reconciliation, DB backups, and monthly reports. All seven have BullMQ queues.
The rule for what goes async: anything that can fail and must retry, anything that takes longer than the user should wait, or anything where the client doesn't need the result immediately.
Email is the clearest case. Sending a welcome email takes 200-500ms (SMTP roundtrip). The user should not wait for this. If the email provider is down, we want to retry — not fail the signup. Async + retry solves both.
Analytics MV refresh is even clearer. Refreshing a materialized view takes 1-30 seconds (full scan of user_attempts). This runs on a cron schedule (every 2 hours) in a background worker. No user is waiting for it.
In AttemptIQ:
// lib/queues.ts
export const analyticsRefreshQueue = new Queue('analytics-refresh', { connection, defaultJobOptions });
export const emailQueue = new Queue('email', { connection, defaultJobOptions });
// ... 5 more queues
The tradeoff: Async means eventual consistency. A user submits a practice session — their analytics don't update instantly. The MV refreshes within 2 hours. For exam prep analytics, this is fine. For a stock trading dashboard, it wouldn't be.
Q: What happens when a job fails?
Answer: BullMQ retries with exponential backoff. Configuration:
const defaultJobOptions = {
attempts: 3,
backoff: { type: 'exponential' as const, delay: 5000 },
removeOnComplete: { count: 100 },
removeOnFail: { count: 500 },
};
Attempt 1 fails → wait 5s → attempt 2 → wait 10s → attempt 3 → if still failing, job moves to the failed set in Redis.
The failed set is retained (up to 500 entries). We can inspect failed jobs, see the error, and manually retry.
For the analytics MV refresh specifically, failure is recorded in cron_health:
await writeCronHealth(`analytics-refresh:${viewName}`, false, message);
/api/health reads cron_health and returns 503 if any cron job has been failing. UptimeRobot alerts us.
Anticipating the follow-up: You might ask what happens if the worker process itself crashes (PM2 kills it). BullMQ uses Redis as the source of truth. Jobs in "active" state (being processed when the crash happens) are moved back to "waiting" on the next worker startup — BullMQ's stall detection (via heartbeat) handles this. No jobs are lost.
Q: How do you prevent a job from running twice?
Answer:
BullMQ's upsertJobScheduler with a named scheduler ID:
await queue.upsertJobScheduler(
`refresh-${mv.name}`, // unique scheduler ID
{ pattern: `${mv.delayMinutes} */2 * * *` },
{ name: `refresh-${mv.name}`, data: { viewName: mv.name, concurrently: true } },
);
upsertJobScheduler is idempotent — calling it with the same ID either creates the scheduler (first time) or updates it (subsequent). If the worker restarts and calls this again, it doesn't create a duplicate scheduler.
For one-off jobs (email, payment recon), the idempotency is in the job data. The email worker checks if the email was already sent before sending. The payment recon worker checks payment status before processing.
The tradeoff:
Redis is the single source of truth for job state. If Redis loses data (no AOF), scheduled jobs could be lost. We use appendfsync everysec — at most 1 second of data loss. Losing one scheduled analytics refresh (the next refresh catches up) is acceptable. Losing an email job (user doesn't get their receipt) is worse — for critical emails, we could also write to the DB before enqueueing.
6. Performance and Caching
Q: Where are the slowest parts of the system?
Answer: Three bottlenecks in order: analytics queries, mock test session creation (weak area selection), and the first paint for authenticated pages.
Analytics: before materialized views, subject stats took 800ms on a user with 50,000 attempts (full aggregation over user_attempts). MVs reduced this to 2ms.
Weak area session creation: the _selectWeakAreaQuestions function runs several queries — subject stats from the MV, topic stats, and a ranked question selection per subject. Total: 3-5 queries, 50-200ms. This is acceptable (session creation is a user action, not a hot path).
First paint for authenticated pages: Next.js SSR renders the page on the server. For the dashboard, this includes a session lookup + user profile query. The page is not cacheable (user-specific). At 500ms SSR time, the user waits. We mitigate with React Suspense boundaries — the shell renders immediately, data streams in.
In AttemptIQ:
Analytics response: ~2ms (MV hit + Redis cache)
Session creation: ~100ms (question selection + insert)
Attempt submit: ~20ms (session fetch + attempt insert)
Anticipating the follow-up:
You might ask why not cache everything in Redis. We cache analytics (10min TTL). We don't cache session state because sessions change with every question answered — the cache would be stale immediately. We don't cache question content because it's already fast (index lookup on questions.id).
Q: What is cached, where, and why?
Answer: Three-layer caching:
Cloudflare (edge, CDN): Public pages — homepage, pricing, SEO content pages. TTL: 1 hour. Served from Cloudflare's edge nodes, never hitting our VPS. On admin approval of a question (new public content), we send a Cloudflare cache purge.
Redis (application cache): Analytics data per user. Key: analytics:subject_stats:{userId}. TTL: 10 minutes. On cache miss, query the MV, store in Redis. Next request within 10 minutes: Redis hit, 0.1ms response.
PostgreSQL shared_buffers (implicit): Frequently-accessed index pages and heap pages live in PostgreSQL's in-memory buffer pool (128MB default). Hot data stays in RAM. No explicit code needed — PostgreSQL manages this automatically.
Why not cache at the Next.js fetch() level for user data:
Next.js's fetch() cache is per-request in server components. It deduplicates calls within a single render tree, but doesn't persist across requests. Redis is the right cache for cross-request persistence.
The tradeoff: Cache invalidation. When a user submits more practice questions, their analytics are stale until the TTL expires (up to 10 minutes) or until the next MV refresh. Users see slightly old accuracy percentages. For exam prep analytics, this is acceptable — you don't need real-time feedback on aggregate stats.
Q: What happens when cache and database go out of sync?
Answer: The Redis analytics cache TTL is 10 minutes. In the worst case, a user sees analytics that are 10 minutes old. After TTL expiry, the next request hits the MV (which refreshes every 2 hours), stores fresh data in Redis.
The deeper question: what if the MV itself is stale (because the refresh failed)? The cron_health table tracks refresh failures. /api/health returns 503 if a job missed two cycles. In the failure case, analytics shows data from the last successful MV refresh — potentially 4 hours old (missed one cycle) or more. We don't expose the staleness timestamp to users, which is a current gap in observability.
Anticipating the follow-up:
You might ask about cache stampede — all TTLs expiring simultaneously, everyone hitting the DB. With Redis, this is mitigated by staggering TTLs (add a small random jitter: TTL = 10 * 60 + Math.random() * 60). We don't currently do this — it's a known gap that matters at scale.
Q: How would you handle 10,000 concurrent test-takers?
Answer: Current setup handles ~1,000-2,000 concurrent users. At 10x, the bottleneck order is:
-
CPU (Next.js SSR): 4 PM2 workers, 4 vCPUs. At 10,000 concurrent users with 100ms SSR each, we need ~1,000 req/sec sustained. That's 100 CPU-seconds/sec — 25x our 4 vCPU capacity. First bottleneck. Fix: horizontal scaling (add a second server + load balancer) or optimize SSR (cache more, reduce server computation).
-
PostgreSQL connections: pgBouncer's 25 real PG connections handle ~250 concurrent queries (at 100ms each). At 10x load, attempt submissions arrive faster. Fix: raise
default_pool_sizeto 50, upgrade to a CX42 with more RAM. -
Redis: Rate limiting sorted sets, analytics cache, BullMQ — all hit Redis. A single Redis instance handles ~100,000 ops/sec. Not a bottleneck at 10x.
-
Network/Cloudflare: Cloudflare absorbs traffic spikes. Not a bottleneck.
The real answer: at 10x traffic (if it's sustained, not a spike), we'd move to a dedicated DB server and add a second app server with a load balancer. The monolith architecture makes horizontal scaling straightforward — each Next.js worker is stateless.
7. Infrastructure and Deployment
Q: Walk me through your infra topology.
Answer: One Hetzner CX32 VPS (4 vCPU, 8GB RAM, 80GB NVMe, Ubuntu 24.04). Two products on it (AttemptIQ and ScoreResume — entirely separate processes, DBs, and Nginx configs).
Internet
→ Cloudflare (DNS, CDN, DDoS, TLS)
→ Nginx (port 443, TLS termination, rate limiting, proxy)
→ PM2 "attemptiq" (4 workers, Next.js, port 3000)
→ pgBouncer (port 6432) → PostgreSQL (port 5432)
→ Redis (port 6379)
→ PM2 "attemptiq-worker" (1 worker, BullMQ)
→ same PostgreSQL + Redis
Everything binds to 127.0.0.1. PostgreSQL, pgBouncer, and Redis are not externally accessible. Only Nginx (port 80/443) is exposed to the internet.
Why Hetzner over AWS: Hetzner CX32 costs ~€20/month. An AWS t3.large (2 vCPU, 8GB) costs ~$60/month, without managed PostgreSQL or Redis. A comparable AWS setup (EC2 + RDS + ElastiCache) is $200-300/month. At bootstrapped SaaS prices, Hetzner + self-managed is 10x cheaper. The operational overhead is real (you manage everything), but for a solo-built product, it's a reasonable tradeoff.
The tradeoff: No automatic failover. If the VPS goes down, the site is down until Hetzner fixes the hardware or we restore from backup. We accept this: UptimeRobot monitors and alerts. Hetzner's SLA is 99.9% for the network, with fast support response. For exam prep (not a 24/7 trading platform), occasional downtime is acceptable.
Q: How does Cloudflare fit in?
Answer: Cloudflare does four things for us:
-
TLS termination: Cloudflare terminates HTTPS at their edge. Traffic between Cloudflare and our server is re-encrypted (Full Strict mode). Our SSL cert (Let's Encrypt via Certbot) only needs to be valid for Cloudflare, not for end users.
-
CDN: Public pages (homepage, pricing, SEO content) are cached at Cloudflare's 300+ edge nodes. A user in Tokyo hits Cloudflare's Tokyo node, not our Frankfurt VPS. Latency: 20ms vs 200ms.
-
DDoS protection: Cloudflare absorbs volumetric attacks (floods of UDP/TCP packets) before they reach the VPS. Our server never sees the attack traffic.
-
IP filtering:
CF-Connecting-IPheader gives us the real user IP (not Cloudflare's proxy IP). Our rate limiter reads this header:
// lib/rate-limit.ts
const ip = request.headers.get('cf-connecting-ip') ??
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
null;
The tradeoff: We're dependent on Cloudflare's DNS. If Cloudflare has an outage (rare), our site is unreachable even if our server is healthy. We accept this — Cloudflare's reliability is significantly better than our single-server setup.
Q: How does deployment work? Is there downtime?
Answer: GitHub Actions triggers on push to main. The pipeline SSHs into the VPS and runs:
cd /var/www/attemptiq
git pull origin main
npm ci --production=false
npm run build
pm2 reload attemptiq --update-env
pm2 reload attemptiq-worker --update-env
Zero-downtime via PM2 cluster mode: pm2 reload performs a rolling restart. PM2 starts a new worker, waits for it to signal "ready" (wait_ready: true in ecosystem.config.js), then kills one old worker. At no point are all 4 workers down simultaneously. Requests in-flight on the old worker complete before it's killed (kill_timeout: 5000).
The exception: schema migrations. If a migration adds a column or changes a constraint, there's a window where the new code expects the column but the old code doesn't. We handle this with backwards-compatible migrations (add nullable columns first, deploy code, then add NOT NULL constraints after all workers are on new code).
The PM2 restart gotcha: pm2 restart reuses stale env vars from PM2's internal state — new .env changes don't take effect. After any env var change:
pm2 stop attemptiq && pm2 delete attemptiq && pm2 start ecosystem.config.js && pm2 save
Anticipating the follow-up:
You might ask about rollback. We keep the Git history. Rolling back is git checkout <last-good-sha>, rebuild, restart PM2. If a DB migration was deployed with the broken code, we may need to run a down migration first. Each migration in migrations/ has a corresponding down script.
8. Failure Modes and Resilience
Q: What is the most dangerous failure in your system?
Answer: Silent data corruption in the payment flow. Specifically: a user pays, the payment is captured, but the plan upgrade is not applied (because the server crashed between the two operations).
This is dangerous because it's not visible as an error. The payment provider shows success. The user thinks they paid. The DB shows payments.status = 'captured'. But user_profiles.plan_tier is still 'free'. The user experiences the product as if they never paid.
How we prevent it: Atomic transaction: all three operations (capture payment, upgrade plan, mark webhook processed) happen in one transaction. If any step fails, all roll back. The payment gateway retries the webhook (it didn't get a successful response), and the transaction runs cleanly from the beginning.
await client.query('BEGIN');
// Step 1: UPDATE payments WHERE status='pending' RETURNING id
// Step 2: UPDATE user_profiles SET plan_tier, plan_expires_at
// Step 3: UPDATE webhook_events SET processed = true
await client.query('COMMIT');
The residual risk:
If the server crashes after PostgreSQL commits but before the Node.js process sends 200 to the webhook gateway, the gateway retries. The second delivery hits the idempotency check (insertEvent returns 'already_processed') and is skipped. Correct behavior.
Q: What data could you lose, and under what conditions?
Answer: Three categories, in order of risk:
Redis data (cache, rate limit state, job queue): Redis uses appendfsync everysec. On a crash, we lose up to 1 second of Redis writes. For cache data: acceptable (cache miss on restart). For rate limit state: acceptable (users get a brief window of unlimited requests — not catastrophic). For BullMQ jobs: jobs in-flight at crash time are replayed on restart (stall detection). Jobs in the queue but not yet started: safe (in Redis, fsynced within 1 second).
PostgreSQL data: WAL + fsync = on. Every committed transaction is on disk before the client receives confirmation. Zero data loss on crash, assuming the disk itself doesn't fail. The disk is NVMe on Hetzner — RAID is not configured (single drive). If the drive fails: last backup (daily pg_dump to Cloudflare R2). Potential loss: up to 24 hours of data.
What could cause 24 hours of data loss: Drive failure between backups. Probability: low. Hetzner's NVMe drives have enterprise-grade reliability. Weekly Hetzner VPS snapshots provide additional recovery points. Monthly restore tests verify backup integrity.
Anticipating the follow-up: You might ask about point-in-time recovery (PITR). We don't have WAL archiving to S3/R2 — which would enable PITR (restore to any moment in time, not just the daily backup). This is a known gap. At current user count, daily backups are acceptable. At 10,000 paying users, WAL archiving is worth the complexity.
Q: How would you know if something was silently failing?
Answer: Four layers of visibility:
-
UptimeRobot: pings
/api/healthevery 5 minutes. If the endpoint returns 503 or is unreachable, SMS + email alert within 10 minutes. -
/api/healthendpoint:
// app/api/health/route.ts
// Checks: db (SELECT 1), redis (PING), cron_health table
// Returns 503 if any check fails
// Requires HEALTH_SECRET token to return details
-
cron_healthtable: Every background job writes its completion status. If a job misses two cycles (detected by comparinglast_run_attonow() - 2 * interval), the health check returns 503. -
Sentry: Unhandled exceptions in API routes and server components are automatically captured. Error rate spikes show up in Sentry's dashboard.
The gap: slow degradation without errors. If the analytics MV refresh takes 30 seconds instead of 5 (because the table grew), no error is thrown — the job succeeds. We don't track job duration. This is a monitoring gap: we should add a last_run_duration_ms to cron_health and alert on p99 > threshold.
9. Scaling Hypotheticals
Q: AttemptIQ gets featured and traffic 10xs in an hour. What breaks first?
Answer: Walk through the bottleneck order:
Immediate (within minutes): Cloudflare absorbs the traffic spike at the edge. Cached public pages (homepage, pricing) are served from Cloudflare's edge — the VPS doesn't see this traffic. No impact.
5-10 minutes: Authenticated routes (practice, analytics, dashboard) hit the VPS. 4 PM2 workers handle SSR. At 10x load, each worker's CPU spikes. Node.js is single-threaded for CPU work — SSR is CPU-bound. Workers start queuing requests. PM2's cluster mode distributes across 4 cores, but 4 cores are fixed.
10-15 minutes: pgBouncer's 25 connections are all busy (test submissions, session creation). New requests queue at pgBouncer. connectionTimeoutMillis: 10000 in pg.Pool — connections that wait >10s throw an error. API routes return 500.
15+ minutes: RAM pressure. 4 Next.js workers × 300MB = 1.2GB. At spike load, workers grow. Node.js's GC runs more frequently. At 8GB RAM with PostgreSQL, Redis, and pgBouncer also running, there's ~5GB for Next.js. This should hold, but monitor via pm2 monit.
Immediate fix: Raise Nginx's rate limit zones to let more traffic through (currently 300 req/min for public, 120 req/min for API). This buys time. Medium-term: upgrade to CX42 (8 vCPU, 16GB).
Q: Your database disk is at 95%. What do you do?
Answer: Diagnosis first, then action. Don't delete anything without knowing what's using the space.
# 1. Find what's using disk
du -sh /var/lib/postgresql/16/main/* # PostgreSQL data directory
du -sh /var/log/pm2/* # PM2 logs (often the culprit)
du -sh /var/www/attemptiq/.next/* # Next.js build artifacts
# 2. Check PostgreSQL table sizes
SELECT schemaname, tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 10;
# 3. Check bloat (dead tuples from MVCC)
SELECT relname, n_dead_tup, n_live_tup
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
Common culprits:
- PM2 logs:
pm2 flushclears logs. Configuremax_logs_sizein PM2 to auto-rotate. - PostgreSQL WAL: If archiving is configured (it's not currently), old WAL segments accumulate.
pg_archivecleanupremoves old segments. - Table bloat:
VACUUM FULLreclaims bloated table space (rewrites the table — takes a lock, causes downtime).VACUUM(without FULL) marks space for reuse but doesn't return it to the OS. user_attemptsgrowth: The biggest table. If growing faster than expected, check if old sessions are being retained. Consider partitioning bycreated_atyear.
The emergency option: Hetzner allows live volume expansion (attach extra storage) or a resize to a larger server. One-click, 5-minute operation.
Q: You need to support 50 simultaneous live exams (50,000 users submitting answers simultaneously). What changes?
Answer: 50,000 simultaneous answer submissions is 50,000 writes/second if everyone submits at the same moment (end of exam). In practice, submissions are spread over 3 hours — but there's a spike at the last 5 minutes.
What breaks:
-
pgBouncer pool: 25 connections × ~100ms per INSERT = 250 submissions/second. At 50,000: 200x throughput needed. We'd need
default_pool_size = 200— which means 200 PostgreSQL backend processes, each using ~10MB = 2GB just for PG process overhead. Plus shared_buffers, working memory. A single server can't handle this. -
PostgreSQL WAL: Every
user_attemptsINSERT goes through WAL (fsync on commit). At 50,000/second,fsyncbecomes the bottleneck. Solution: write-optimized configuration (synchronous_commit = offtrades up-to-1-WAL-page durability for 10x write throughput), or a dedicated high-IOPS database server. -
BullMQ worker: Analytics refresh and email jobs spike post-exam. Single worker handles this sequentially — acceptable.
The architecture change for 50,000 concurrent: separate the write path from the read path. Writes (attempt submission) go to a write-optimized PostgreSQL primary. Reads (next question, analytics) go to a read replica. Both behind pgBouncer. App connects to two pools: one read, one write. This is a significant architecture change from the current monolith.
10. Product and Engineering Tradeoffs
Q: What did you cut corners on and why?
Answer: Three things: PITR (point-in-time recovery), WAL-level replication, and real-time analytics.
PITR: Daily pg_dump backup, not WAL archiving. Restoring from pg_dump can only recover to the last backup, not to any arbitrary moment. Implementing WAL archiving to Cloudflare R2 would give us PITR — restore to any second in the last N days. We didn't build it because at current user count, losing up to 24 hours of data (extremely unlikely scenario) is an acceptable risk.
Real-time analytics: Analytics refresh every 2 hours via MV. Users see data that's up to 2 hours old. Real-time would require either no materialized views (expensive on-demand aggregation) or event streaming (Kafka/Flink-level infrastructure). The tradeoff is user experience vs infrastructure cost. Exam prep analytics don't need real-time.
No distributed tracing: Sentry captures errors and 10% of transactions. We don't have request-level tracing from Nginx → Node.js → pgBouncer → PostgreSQL. If a specific query is slow, we diagnose via pg_stat_statements — manual, not automated. At current scale, this is enough.
Anticipating the follow-up: You might ask if these corners will bite us. PITR — maybe, if a bug corrupts data and we don't notice within 24 hours. Real-time analytics — probably not, exam prep users don't need sub-second analytics. Distributed tracing — yes, as traffic grows, diagnosing intermittent slowness will require better tooling.
Q: What technical debt exists and what would it cost to fix?
Answer: Three items:
-
PM2 log management: Currently
pm2 flushis manual. As log volume grows (10,000 users × multiple events), PM2 logs become gigabytes. Fix: configure log rotation (pm2 install pm2-logrotate) or ship logs to a centralized service (Logtail, Papertrail). Cost: 2-4 hours. -
No caching TTL jitter: All analytics cache keys expire at exactly the same TTL. Under simultaneous user activity (e.g., post-exam result page load), many keys expire simultaneously and all miss to the DB. Fix: add
Math.random() * 60jitter to TTL. Cost: 1 hour. -
as unknown asTypeScript casts in payment webhook: The Dodo webhook payload is typed asRecord<string, unknown>because the SDK doesn't export the payload type. We access fields with casts. If the payment provider changes their payload shape, TypeScript won't catch it. Fix: write a Zod schema for the webhook payload, validate it at the boundary. Cost: 3 hours.
11. Observability — Sentry + PostHog
Q: What is the difference between error tracking and product analytics — why do you need both?
Answer: Error tracking (Sentry) answers "what broke and where in the code." Product analytics (PostHog) answers "what are users doing and where do they stop."
You need both because they're blind to each other's domain. Sentry tells you TypeError: Cannot read property 'id' of undefined in sessions.service.ts line 142. It doesn't tell you that 30% of free users abandon the flow before starting their first session. PostHog tells you the funnel drop-off point. It doesn't give you a stack trace.
In AttemptIQ:
- Sentry captures unhandled exceptions in API routes, Server Components, and the browser.
- PostHog tracks user flows:
signup_completed→first_practice_started→first_question_answered→practice_session_completed. The conversion between these events is the activation funnel.
If users drop off between first_practice_started and first_question_answered, PostHog shows us where. Sentry would only show us if there's an error causing the drop-off. If the drop-off is due to confusing UX (no error thrown), only PostHog sees it.
Q: What does Sentry actually capture that console.log does not?
Answer:
Stack traces that resolve to source code. In production, Next.js minifies and bundles JavaScript. An error at bundle.js:1:42853 is useless. Sentry uploads source maps at deploy time — Sentry receives the minified location and translates it to the original file, line, and column.
Sentry also captures:
- Breadcrumbs: the sequence of events (HTTP requests, console logs, user actions) leading up to the error. You see the last 30 seconds of activity before the crash.
- User context: which user (by ID, not by name/email) triggered the error.
- Environment: browser version, OS, Next.js runtime (edge vs Node.js).
- Grouping: Sentry groups identical errors. 1,000 users hitting the same null pointer error shows as one issue with count=1,000, not 1,000 separate issues.
// sentry.server.config.ts
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1,
beforeSend(event) {
if (event.extra) {
delete event.extra['correct_answer'];
delete event.extra['selected_answer'];
}
return event;
},
});
console.log has none of this. It writes to stdout (PM2 captures it). You'd have to grep through log files manually.
Q: What is a sourcemap and why does it matter for Sentry in production?
Answer:
A sourcemap is a JSON file that maps positions in minified/compiled code back to positions in the original source. During Next.js build, TypeScript is compiled to JavaScript, then bundled and minified. The minifier renames variables (userId → a), removes whitespace, and collapses multiple files.
Without sourcemaps, a Sentry error shows: TypeError at bundle.js:1:82347. Useless.
With sourcemaps uploaded to Sentry: TypeError at services/sessions.service.ts:142:18. Actionable.
The security concern: sourcemaps contain your original source code. Shipping them to the browser means anyone can read your server-side code in the browser's devtools. The correct approach:
- Build generates sourcemaps.
- CI/CD uploads sourcemaps to Sentry (
@sentry/webpack-plugin). - CI/CD deletes sourcemaps from the build output.
- The browser gets the minified code only. Sentry has the sourcemaps to resolve stack traces server-side.
Q: A user reports a bug but Sentry shows no error. How?
Answer: Four possible explanations:
-
The bug is a UX issue, not an exception. Wrong text displayed, wrong calculation shown, confusing flow — no JavaScript exception thrown. Sentry doesn't capture these. PostHog session replay might show the user's actions.
-
The error is handled. A try/catch somewhere swallowed the error and returned a fallback. Sentry only captures unhandled exceptions or explicitly
Sentry.captureException()-called ones. -
Sentry sampling is at 10%.
tracesSampleRate: 0.1means only 10% of transactions are traced. If the error occurs in a non-traced transaction, it won't appear. (Note: errors are captured at 100%; only tracing/performance is sampled.) -
The error is in the browser but Sentry client config filtered it. We ignore
ResizeObserver loop limit exceeded(browser noise). Any error matchingignoreErrorsis silently dropped.
Diagnosis:
- Check PostHog session replay for the user's session around the reported time.
- Check PM2 logs:
pm2 logs attemptiq | grep "error"— console.error calls show here even without Sentry. - Ask the user for their browser, OS, and exact steps to reproduce.
Q: PostHog and Sentry both run on every page load — what is the performance cost?
Answer: Both are loaded as client-side JavaScript. The cost:
- Sentry: ~50KB gzipped. Loads asynchronously after the main bundle. Adds ~20-50ms to Time to Interactive on slow connections.
- PostHog: ~30KB gzipped. Same async load. Adds ~10-20ms.
Both send data via fetch() or sendBeacon() in the background — no blocking network requests.
The optimization: both scripts are loaded with async or defer. They don't block rendering. The main Largest Contentful Paint (LCP) is not affected.
What to watch for: if PostHog session recording (session_recording: { maskAllInputs: true }) is enabled, it captures DOM mutations (using MutationObserver) on every user interaction. On complex pages with frequent re-renders (React state updates), this can add CPU overhead. We mask all inputs (passwords, answers) to avoid PII capture.
The honest tradeoff: we pay ~70KB of JavaScript and ~30ms of TTI for crash visibility and funnel analytics. For an exam prep product where users spend 30+ minutes per session, this is a rounding error.
12. Load Testing with k6
Q: What is k6 actually simulating — what is a virtual user?
Answer: A virtual user (VU) in k6 is a goroutine (Go's lightweight thread) that runs your test script in a loop, from top to bottom, then repeats. Each VU maintains its own HTTP connection pool and cookie jar — it behaves like a real browser tab (minus rendering).
The default function in the k6 script is one iteration. VU 1 runs the function, sleeps, runs again. VU 50 runs the function concurrently. At any moment, you might have 50 functions in-flight simultaneously — but each VU is sequential within itself (line 1 completes before line 2 runs).
This is different from a real browser in one critical way: no JavaScript execution. k6 sends HTTP requests directly. It doesn't run the React bundle, doesn't parse the DOM, doesn't load images or fonts. So k6 load numbers are optimistic — real browser load hits more endpoints (asset requests, analytics beacons, PostHog).
In AttemptIQ:
// load-tests/k6-load.js
export const options = {
stages: [
{ duration: '2m', target: 50 }, // ramp up to 50 VUs
{ duration: '5m', target: 50 }, // hold at 50
{ duration: '1m', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<3000'], // p95 < 3s
},
};
50 VUs each completing one iteration every ~7 seconds (4 routes + 4 seconds of sleep) = ~350 requests/minute. That's the simulated load.
Q: Your k6 run shows p99 latency at 4 seconds under 500 VUs. Walk me through diagnosing where the bottleneck is.
Answer: Systematic diagnosis — eliminate layers from outside in:
Step 1: Is it Cloudflare or the server?
Run k6 directly against http://localhost:3000 from the VPS itself (bypassing Cloudflare and Nginx). If latency drops dramatically, the bottleneck is in the network layer. If latency stays at 4s, it's in the application or database.
Step 2: Is it Nginx? Check Nginx access logs for request processing time:
tail -f /var/log/nginx/access.log
# Look for the $request_time field — if it's small, Nginx is not the bottleneck
Step 3: Is it the Node.js process?
pm2 monit # watch CPU per worker
If one worker is at 100% CPU: SSR is CPU-bound. If all workers are <50%: CPU is not the bottleneck.
Step 4: Is it the database?
# Connect to pgBouncer as admin
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
# Look at: cl_waiting (clients waiting for a connection)
# If cl_waiting > 0, pgBouncer pool is exhausted
Also run in PostgreSQL:
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC LIMIT 10;
The slowest total-time queries are your bottleneck.
Step 5: Is it Redis?
redis-cli INFO stats | grep instantaneous_ops_per_sec
redis-cli INFO latency
If Redis latency >1ms: either memory pressure (eviction) or a slow command blocking the single thread.
Likely answer at 500 VUs: pgBouncer pool exhaustion. SHOW POOLS shows cl_waiting > 0. Fix: raise default_pool_size from 25 to 50, monitor PostgreSQL memory.
Q: You run a k6 test and at exactly 200 VUs the error rate spikes to 30%. What is your hypothesis?
Answer: A hard threshold was hit at exactly 200 VUs — not gradual degradation, but a cliff. That means a fixed-size resource was exhausted.
Most likely cause: pgBouncer max_client_conn = 200. Each VU holds open a connection to the Next.js server, which holds a connection in pg.Pool. At 200 VUs, the 201st app connection can't get a pgBouncer slot. pgBouncer rejects it. pg.Pool times out (connectionTimeoutMillis). Route handler throws. 500 error.
Secondary hypothesis: Nginx worker_connections limit. If Nginx is configured with low worker_connections, the 201st connection is refused at the proxy level.
How to confirm:
# Check pgBouncer
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW CLIENTS;"
# If total_clients ≈ 200, pgBouncer is the limit
# Check Nginx error log
tail -f /var/log/nginx/error.log
# "worker_connections are not enough" confirms Nginx limit
The connection between k6 and pgBouncer pool size: k6's 200 VUs each hold one HTTP connection open during a request. Each HTTP request holds a pg.Pool connection for the duration of the DB query. At peak: 200 VUs × ~10% currently querying DB = 20 concurrent pgBouncer connections. That's not the issue. But if the test script has slow routes (analytics, mock test setup), those hold DB connections longer — more concurrent pgBouncer connections.
Q: What does a k6 result tell you that Sentry or PostHog cannot?
Answer: k6 tells you system behavior under controlled concurrent load. Sentry and PostHog tell you what happened to real users.
Specifically, k6 gives you:
- p95 and p99 latency under N VUs: real users are one VU; k6 lets you test 500 simultaneous users before they exist.
- Error rate as a function of concurrency: the threshold where your system degrades — a cliff at 200 VUs or a gradual slope.
- Throughput (requests/second): how many requests your system can process before latency climbs.
- Custom metrics on specific routes:
dbRouteLatencyin AttemptIQ's k6 script tracks only DB-heavy routes separately from lightweight routes.
Sentry and PostHog tell you what actually happened. k6 tells you what would happen. This is the distinction: k6 is pre-production capacity planning; Sentry/PostHog is post-production observability.
Q: How do you load test without destroying your production database?
Answer: Two rules in AttemptIQ's k6 script:
-
Read-only routes under load test: the k6-load.js only hits GET endpoints — home, health, practice page, analytics page, mock test setup page. No POSTs (no session creation, no answer submission, no payment). Read routes hit the DB with SELECT queries; they don't mutate data.
-
Session cookie is required for writes: authenticated routes need a real session cookie (
SESSION_COOKIEenv var). Without it, the script skips authenticated routes. Running withoutSESSION_COOKIEis the safe default.
// load-tests/k6-load.js
if (!SESSION) {
sleep(2);
return; // skip authenticated routes if no session
}
For write-path load testing: use a staging environment with a seeded copy of the DB. Never load-test writes against production — you'd create thousands of test sessions, attempt records, and potentially trigger payment flows.
13. VPS and Self-Managed Server
Q: Walk me through exactly what happens when your Hetzner server reboots unexpectedly.
Answer: In order of recovery:
-
Linux kernel boots: BIOS → bootloader (GRUB) → kernel. Takes ~5-15 seconds.
-
systemd starts services: PostgreSQL, Redis, Nginx, pgBouncer are all configured as systemd services with
Restart=always. They start automatically. Takes ~10-30 seconds. -
PostgreSQL crash recovery: On startup, PostgreSQL checks if there was a clean shutdown. If not, it replays the WAL from the last checkpoint. Uncommitted transactions are rolled back. Committed transactions are applied to data pages. This ensures database consistency. Takes seconds to minutes depending on how much WAL needs replaying.
-
PM2 starts app: PM2 is configured with
pm2 startup— systemd unit that runspm2 resurrect(restores the last saved process list). Bothattemptiqandattemptiq-workerrestart. Takes ~30-60 seconds (Next.js startup). -
Nginx starts: Already serving traffic. Routes to PM2's newly started workers.
-
Total downtime: ~1-3 minutes from crash to serving requests. UptimeRobot detects within 5 minutes and sends an alert.
What users experience: HTTP 502 Bad Gateway (Nginx is running but Next.js isn't yet) for 1-3 minutes. Then normal operation.
What doesn't auto-recover: if a migration was half-applied (PostgreSQL was killed mid-migration), the DB may be in an inconsistent state. Manual intervention required: check migrations/ table for incomplete migrations, decide whether to complete or roll back.
Q: What is Nginx doing in front of your Node process — why not expose Node directly?
Answer: Nginx does six things that Node.js shouldn't handle:
-
TLS termination: managing SSL certificates, handling HTTPS, cipher negotiation. Node.js can do this, but Nginx is far better optimized for it (uses OpenSSL with hardware acceleration).
-
Rate limiting:
limit_req_zonezones for signup (5/hr), login (10/hr), API (120/min). Nginx rejects over-limit requests at the proxy layer — never reaching Node.js. If Node.js handled rate limiting alone, the Redis rate limiter still runs but every request still hits Node.js first. -
Static file serving: Nginx serves
/_next/static/*directly from the filesystem (CDN-level speed) without hitting Node.js for each request. -
Security headers:
X-Frame-Options,X-Content-Type-Options,Strict-Transport-Security,Content-Security-Policy— added in Nginx config, applied to every response regardless of which Next.js route handled it. -
Connection management: Nginx is event-driven and handles thousands of concurrent connections in a single process. Node.js in cluster mode handles this too, but Nginx is more efficient for connection-level work (slow client buffering, keep-alive management).
-
Multiple services on one IP: Both AttemptIQ and ScoreResume run on the same VPS. Nginx routes by domain name —
attemptiq.com→ port 3000,scoreresume.com→ port 3001.
Q: You SSH in and the server feels slow. What are the first 5 commands you run?
Answer:
# 1. Is it CPU?
top -bn1 | head -20
# Look for: load average (should be < number of CPUs = 4)
# Look for: any process at 100% CPU (PostgreSQL, Node.js, or something unexpected)
# 2. Is it RAM / swap?
free -h
# Look for: swap used > 0 (swap usage means RAM is exhausted, disk I/O is killing you)
# 3. Is it disk I/O?
iostat -x 1 3
# Look for: %util > 80% (disk is saturated — likely PostgreSQL or logging)
# Look for: await > 10ms (disk latency — indicates I/O wait)
# 4. What are PostgreSQL's active queries?
psql -U attemptiq -c "SELECT pid, now() - query_start as duration, state, left(query, 80) as query FROM pg_stat_activity WHERE state != 'idle' ORDER BY duration DESC LIMIT 10;"
# Long-running queries block other queries (lock contention)
# 5. Are there too many connections?
psql -p 6432 -U pgbouncer pgbouncer -c "SHOW POOLS;"
# cl_waiting > 0 means connections are queuing — pool is exhausted
The sequence matters: CPU → RAM → disk → DB queries → connections. Each rules out a layer before going deeper.
Q: Why is running a database on the same server as your app a tradeoff?
Answer: They compete for the same physical resources: CPU, RAM, disk I/O, and network bandwidth.
CPU: PostgreSQL query execution and Next.js SSR both need CPU. During an analytics MV refresh (CPU-intensive scan), response times for HTTP requests increase because the same 4 CPUs are shared.
RAM: PostgreSQL's shared_buffers (128MB, ideally 25% of RAM = 2GB) keeps hot data in RAM. Next.js workers each use ~300MB. Redis uses ~512MB. Total on 8GB: ~4GB for these three, leaving 4GB for the OS kernel buffers (which also benefit PostgreSQL's buffer cache).
Disk I/O: PostgreSQL reads/writes data pages and WAL. PM2 app logs write to disk. Both compete for the NVMe's I/O bandwidth.
The hidden benefit: no network latency between app and DB. A PostgreSQL query on the same machine takes 0.5-2ms. On a separate server: 1-5ms (network hop adds 0.5-3ms). For a query-heavy workload (many short queries per request), this difference compounds.
When to separate: when PostgreSQL's RAM needs (hot dataset > available RAM) force it to page to disk, or when app CPU usage starves PostgreSQL. At 5,000 paying users with heavy analytics, we'd likely move to a CX42 (more RAM) before separating — but the separate DB server option is on the scale path.
14. The Nasty Questions
Questions where the obvious answer is wrong, or where the right answer requires knowing something non-obvious about how computers work.
NQ1: "You're using pg.Pool with max: 10 per PM2 worker. With 4 workers, that's 40 connections to pgBouncer. But pgBouncer only has 25 real PostgreSQL connections. Doesn't that mean some requests will fail?"
The trap: candidate thinks 40 > 25 means overflow, failures.
Wrong answer: "Yes, we'd need to increase pgBouncer's pool size to 40."
Right answer: 40 app connections → 25 real PG connections is exactly the point. pgBouncer in transaction mode multiplexes: each app connection holds a real PG connection only during an active transaction. The rest of the time, the app connection is idle (waiting for the next HTTP request). At any moment, only 5-10 of the 40 app connections are actively executing SQL. 25 real PG connections easily handles this. If all 40 were simultaneously in a transaction, 15 would queue briefly. That's what pgBouncer is for.
NQ2: "You said correct_answer is never sent to the client before answer lock. But your getFullSessionResult function returns correctAnswer to the client after submission. Isn't that a security violation?"
The trap: candidate conflates "never before lock" with "never ever."
Wrong answer: "You're right, that's a bug."
Right answer: After submission (is_submitted = true), revealing correct_answer is the intended product behavior — it's the post-test review screen. The security rule is: never send correct_answer before the user locks their answer for that question. getFullSessionResult only works on submitted sessions. The route handler explicitly checks session.is_submitted before calling the service. The questionsRepo.findAllByIdsWithAnswers function (which fetches correct_answer) is only called in getFullSessionResult, not in getNextQuestion.
NQ3: "You use hashtext($1::text)::bigint for advisory locks. Two different users could produce the same hash. What happens?"
The trap: candidate thinks this is a data corruption bug.
Wrong answer: "That's a critical bug — it could deadlock two unrelated users or corrupt their sessions."
Right answer: Hash collision means two different users share an advisory lock key. When user Alice and user Bob (by coincidence) hash to the same key, their session creation operations are serialized against each other — one waits for the other. No data corruption: each INSERT still has its own WHERE user_id = $1 condition. The only effect is unnecessary serialization: Alice and Bob can't create sessions simultaneously when they could. The probability for two users colliding: 1 / 2^32 ≈ 0.00000002%. With 10,000 users, expected collisions from any pair: C(10000,2) / 2^32 ≈ 0.01% — one pair, somewhere, occasionally waits unnecessarily. Completely acceptable.
NQ4: "Your middleware does a cookie check but doesn't validate the session in the database. How is this secure?"
The trap: candidate thinks the middleware is the auth layer.
Wrong answer: "It's not — this is a security gap."
Right answer: Middleware is a fast-fail pre-filter, not the auth layer. It runs on Cloudflare's Edge Runtime — no PostgreSQL access. Its job is to redirect unauthenticated users (no cookie at all) to the login page without hitting the Node.js app. For users with a cookie, the real validation happens in the route handler (or server component) where we have PostgreSQL access and call Better Auth to validate the session token against ba_sessions. Two-layer: edge fast-fail (cookie presence) + server full validation (session in DB).
NQ5: "Redis uses appendfsync everysec. Your test session data is in PostgreSQL, not Redis. But if Redis crashes, does anything bad happen to an active test?"
The trap: candidate confuses what's in Redis vs what's in PostgreSQL.
Wrong answer: "Yes, the test session would be corrupted."
Right answer: Active test session state (question IDs, answers, timestamps) is entirely in PostgreSQL. Redis holds: rate limit state, analytics cache, BullMQ job queue, session advisory locks (but those are transaction-scoped and live in PostgreSQL's shared memory anyway). On Redis crash: rate limiting fails open (requests proceed without rate limiting), analytics cache cold-starts (next request hits the MV), BullMQ workers restart and pick up from Redis (jobs in-flight may replay — retry logic handles this). An active test session continues normally — the session is in PostgreSQL, the answer submission hits PostgreSQL, the user doesn't notice Redis restarted.
NQ6: "You do REFRESH MATERIALIZED VIEW CONCURRENTLY. Is this non-blocking?"
The trap: candidate takes "concurrent" to mean "completely non-blocking."
Wrong answer: "Yes, users can read from the MV during refresh with no impact."
Right answer: REFRESH CONCURRENTLY is non-blocking for readers — they can query the MV while it refreshes. But it is not free: it acquires a ShareUpdateExclusiveLock (not AccessExclusiveLock like a plain REFRESH). This lock prevents concurrent REFRESH operations and concurrent vacuums on the MV, but allows reads. The refresh itself runs a full sequential scan of user_attempts — that's CPU and I/O intensive. It can slow other queries if it monopolizes I/O bandwidth. This is why we stagger the 6 MV refreshes 10 minutes apart instead of running them simultaneously.
NQ7: "You use SameSite=Lax on cookies. Does this protect against CSRF?"
The trap: candidate says "yes, completely."
Wrong answer: "Yes, SameSite=Lax prevents CSRF."
Right answer: SameSite=Lax protects against CSRF for state-changing requests (POST, PUT, DELETE) from cross-site origins. For GET requests, Lax still sends the cookie — meaning a cross-site link to GET /api/some-action-that-changes-state would include the cookie. If any of our APIs use GET for state changes (they shouldn't, but it's worth checking), Lax doesn't protect them. Our API design uses GET for reads only and POST/PUT for mutations, which makes Lax sufficient. Strict would be stronger but breaks legitimate cross-site flows (clicking a link from a Gmail email).
NQ8: "PM2 runs 4 workers in cluster mode. Are there any shared data problems between workers?"
The trap: candidate thinks shared state across workers is automatic.
Wrong answer: "No, PM2 cluster mode handles shared state automatically."
Right answer: Each PM2 worker is a separate Node.js process with its own memory space. There is no automatic shared state between workers. If you put data in a JavaScript object in one worker, the other workers don't see it. This is why we use PostgreSQL (shared by all workers) and Redis (shared by all workers) for any shared state. An in-memory cache in one worker would cause the "4 PM2 processes, 4 cache misses" problem we described. The cluster mode benefit is CPU parallelism, not memory sharing.
NQ9: "You mentioned output: 'standalone' reduces Next.js memory footprint by 60%. What exactly does it do?"
The trap: candidate guesses it's about minification.
Wrong answer: "It minifies the JavaScript output."
Right answer: output: 'standalone' changes the production artifact. Instead of requiring the entire node_modules directory at runtime, Next.js traces which Node.js modules are actually imported by the server code and bundles only those. A typical Next.js app's node_modules is 500MB-1GB. The standalone output is 100-200MB. PM2 runs node .next/standalone/server.js instead of next start — it loads far fewer modules, uses less RAM per worker. Four workers × (1GB → 300MB) = saves 2.8GB of RAM on an 8GB server.
NQ10: "Your advisory lock uses pg_advisory_xact_lock (transaction-scoped). What would happen if you used pg_advisory_lock (session-scoped) instead?"
The trap: candidate doesn't know pgBouncer's implication.
Wrong answer: "Session-scoped would be even safer — the lock lasts longer."
Right answer: With pgBouncer in transaction mode, "session" means the lifetime of the real PostgreSQL connection, not the app's connection to pgBouncer. When a transaction commits, pgBouncer returns the real PG connection to the pool. A session-level advisory lock on that connection is now held... on a connection that another app request is about to use. The next request acquires the lock without calling pg_advisory_lock — it inherits the lock from the previous user. Now the next user's session is blocked on an advisory lock they didn't request, and the original user's lock is never released (because pg_advisory_unlock would need to run on the same connection, which pgBouncer doesn't guarantee). Result: deadlocks, hung transactions, cascading failures. pg_advisory_xact_lock releases at COMMIT/ROLLBACK — exactly when pgBouncer returns the connection, so the lifecycle aligns perfectly.
NQ11: "Your getFullSessionResult fetches publicQuestions and questionsWithAnswers as separate queries for the same question IDs. Why not just one query with correct_answer included?"
The trap: candidate says "yes, one query would be more efficient."
Wrong answer: "You're right, we should merge those into one query."
Right answer: The separation is a security invariant enforced at the data layer. findAllByIds() always omits correct_answer — it cannot accidentally leak answers even if called from the wrong place. findAllByIdsWithAnswers() includes correct_answer and is only called in getFullSessionResult (post-submission review). If we merged them into one query everywhere, a future developer who calls findAllByIds() from a pre-submission context might get correct_answer back and accidentally expose it. The repository interface separation makes the security property explicit and hard to violate accidentally.
NQ12: "Your rate limiter fails open on Redis error. Isn't this a security risk?"
The trap: candidate thinks fail-open is always wrong.
Wrong answer: "Yes, we should fail closed — block all requests when Redis is down."
Right answer: The choice between fail-open and fail-closed depends on what you're protecting and the cost of each failure mode. Rate limiting protects against abuse — not against legitimate users. Fail-closed during a Redis outage (even 5 minutes) blocks all legitimate users from the product. Fail-open allows legitimate users through; a brief rate-limit gap during Redis downtime is acceptable. The alternative (fail-closed) would be equivalent to a self-imposed DoS attack any time Redis hiccups. We alert via Sentry on Redis failure, so the team is notified immediately. The abuse risk during a brief Redis downtime is low compared to the UX harm of blocking all users.
NQ13: "You use timingSafeEqual to compare the health check secret. Why not just ===?"
The trap: candidate thinks this is premature optimization.
Wrong answer: "It's overkill — === is fine for a URL token."
Right answer: String comparison with === can leak timing information via a side-channel attack. JavaScript's === on strings returns false on the first mismatched character. If an attacker sends thousands of guesses, they can measure response time: a guess that's wrong at character 10 takes longer than a guess wrong at character 1 (because === compared 10 characters before returning false). By measuring timing, an attacker can reconstruct the secret one character at a time. timingSafeEqual from Node.js's crypto module always compares all bytes, taking constant time regardless of where the mismatch occurs. For a health check secret that controls infrastructure visibility, timing attacks are a real threat model.
NQ14: "You store question_ids as a PostgreSQL array in the sessions table. What's the maximum number of questions a session can hold?"
The trap: candidate says "unlimited" or "it's an array, no limit."
Wrong answer: "There's no limit — arrays in PostgreSQL are dynamic."
Right answer: PostgreSQL arrays are limited by the maximum tuple size: 8KB minus header overhead. A UUID is 16 bytes in binary, but stored as text it's 36 characters + array overhead. An array of 1,000 UUIDs is ~37KB as text — exceeding PostgreSQL's 8KB per-row limit for inline storage. PostgreSQL would use TOAST (The Oversized-Attribute Storage Technique) to store the array in a separate TOAST table. This adds a pointer dereference on every SELECT of that column. In practice, AttemptIQ's mock tests have at most 100-150 questions — well within inline limits. If we ever supported 1,000-question sessions, the TOAST overhead would be noticeable and we'd consider a session_questions junction table.
NQ15: "Your webhook idempotency has two layers: insertEvent CTE and WHERE status='pending' RETURNING id. If the first layer works, why do you need the second?"
The trap: candidate says the second layer is redundant.
Wrong answer: "You don't — if insertEvent returns 'inserted', you know it's safe to proceed."
Right answer: The two layers protect against different failure modes. Layer 1 (webhook_events table) prevents a second webhook delivery from processing the same event. It does not protect against a race where two webhook deliveries arrive simultaneously and both get 'inserted' — this theoretically can't happen because the ON CONFLICT serializes them, but it's a separate concern. Layer 2 (WHERE status='pending' RETURNING id) protects against the case where the webhook_events row was inserted successfully, but our server crashed before COMMIT. On retry: layer 1 returns 'already_processed' (or 'duplicate'). But what if we skip layer 1 (code error, different code path)? Layer 2 still protects: UPDATE payments WHERE status='pending' returns 0 rows (already captured), preventing double-capture. Defense in depth: two independent layers so a bug in one doesn't corrupt payment state.
NQ16: "You use maxRetriesPerRequest: null for BullMQ connections but maxRetriesPerRequest: 3 for the app's Redis connection. If both connect to the same Redis server, why the different configs?"
The trap: candidate says "one config should apply to both."
Wrong answer: "We should standardize — either both null or both 3."
Right answer: The two clients have fundamentally different usage patterns. The app's Redis client runs short, discrete commands: GET key, ZADD key, EXPIRE key. If Redis is momentarily unreachable, 3 retries over a few seconds is the right behavior — eventually succeed or surface the error to the user. BullMQ's Redis connection uses BLPOP — a blocking command that waits indefinitely for a new job. If ioredis retries this command (treating the blocking timeout as a failure), it disconnects and reconnects, interrupting the blocking wait and losing the worker's place in the queue. BullMQ manages the connection lifecycle for long-running commands; ioredis's retry logic would interfere. maxRetriesPerRequest: null disables ioredis's retry for individual commands, letting BullMQ handle reconnection at the connection level.
NQ17: "You said ISR (Incremental Static Regeneration) doesn't work if you read searchParams in a Server Component. Why?"
The trap: candidate doesn't know how Next.js App Router caching works.
Wrong answer: "I'm not sure — it's a Next.js quirk."
Right answer: ISR works by rendering a page once, caching the HTML, and revalidating on a schedule. For ISR to be possible, the page must render identically for all users hitting that URL. searchParams contains the query string — ?sort=asc&filter=math. If a server component reads searchParams, the output depends on the query string, which varies per request. Next.js can't cache this because the cache key would need to include every possible query string combination. So it opts out of ISR entirely and renders dynamically on every request. The fix: read searchParams in a Client Component (client-side, after static HTML is sent) or in a nested Server Component that is wrapped in Suspense (allowing streaming). The parent page can still be statically generated.
NQ18: "Your submitSession function sets is_submitted = true WHERE id = $1 AND user_id = $2 AND is_submitted = false. What happens if a second submitSession call arrives simultaneously?"
The trap: candidate thinks the WHERE clause prevents the second call from succeeding.
Wrong answer: "The second call fails because is_submitted is already true."
Right answer: That's exactly right — the second call's UPDATE matches 0 rows (because is_submitted = false is now true). rowCount is 0. submitSession returns false. The service sees false and throws SessionAlreadySubmittedError. This is the idempotent design: calling submitSession twice is safe — the first succeeds, the second returns a clear error. The client handles this: if it receives SessionAlreadySubmittedError, it knows the submission already went through (perhaps from a previous request that succeeded but whose response was lost in transit) and navigates to the results page.
NQ19: "You mentioned the analytics MV refresh runs REFRESH MATERIALIZED VIEW CONCURRENTLY. What would happen on the very first refresh if you ran it concurrently?"
The trap: candidate says "nothing — it would work fine."
Wrong answer: "It would refresh normally."
Right answer: REFRESH MATERIALIZED VIEW CONCURRENTLY requires the MV to have a unique index. On the very first refresh after CREATE MATERIALIZED VIEW, the MV is populated via CREATE MATERIALIZED VIEW WITH DATA (or a plain REFRESH). The unique index is created on the populated MV. If you try to run CONCURRENTLY before any data is in the MV (or before the unique index exists), PostgreSQL returns: ERROR: cannot refresh materialized view concurrently. The first refresh must be a plain REFRESH MATERIALIZED VIEW (not CONCURRENTLY). This is documented in AttemptIQ's CLAUDE.md as a non-negotiable rule and controlled via the concurrently flag in job data.
NQ20: "Your health check endpoint returns 503 if either DB or Redis fails. But if Redis fails, the site is still partially functional (tests can be taken, sessions work). Why return 503?"
The trap: candidate defends the current implementation or says "good point, we should fix it."
Right answer (nuanced):
You're right that the system degrades gracefully on Redis failure — not catastrophically fails. The 503 is a signal to our monitoring, not necessarily the truth about user impact. There are two ways to interpret /api/health: (1) "is the system fully healthy?" → 503 on any component failure. (2) "is the system serving users?" → 503 only on complete failure.
Currently we use definition (1). The pragmatic reason: our monitoring (UptimeRobot) just checks HTTP status. A 503 means "alert the team." We want to be alerted on Redis failure even if the product still works — because rate limiting is now disabled (fail-open) and a sustained Redis outage requires intervention.
A better design would be: return 200 with { ok: false, degraded: true, redis: false } on Redis failure. UptimeRobot's alert threshold is "2 consecutive 503s" — so brief Redis hiccups don't alert. A better monitoring setup would parse the response body and alert on redis: false regardless of HTTP status. This is a known monitoring gap — the current 503 behavior is a simplification.
NQ21: "You use date_trunc('month', now()) in the practice session limit guard for paid users. What timezone does now() use, and what could go wrong?"
The trap: candidate assumes now() is the user's local time.
Wrong answer: "It resets at midnight the user's local time."
Right answer: PostgreSQL now() returns the current timestamp in the database server's timezone — which on a well-configured server is UTC. date_trunc('month', now()) truncates to the start of the UTC month. A user in IST (UTC+5:30) experiences the monthly reset at 5:30am on the 1st of the month, not at midnight local time. More critically: a user in IST doing practice at 11:45pm on January 31st — that's 6:15pm UTC on January 31st, still January. But if they do it at 12:15am IST on February 1st — that's 6:45pm UTC January 31st, still January! Their IST "February 1st" session counts against January's limit. For a global product, this is a real UX issue. Fix: store user timezone in user_profiles, use date_trunc('month', now() AT TIME ZONE user_timezone) in the query.
NQ22: "Your pg.Pool has connectionTimeoutMillis: 10000. What exactly happens if all 10 connections in one PM2 worker's pool are busy for more than 10 seconds?"
The trap: candidate says "the 11th request waits gracefully."
Wrong answer: "The request queues until a connection is free."
Right answer: The request queues up to connectionTimeoutMillis: 10000 — 10 seconds. If no connection is available within 10 seconds, pool.connect() throws Error: timeout exceeded when trying to connect. This propagates up through the repository → service → route handler. If the route handler doesn't catch it explicitly, Next.js catches it and returns a 500. Sentry captures it. The user sees an error page. The key insight: this is not a database error — it's a connection pool exhaustion error. The database itself is fine. All 10 connections being held for 10 seconds means 10 long-running DB operations on this specific PM2 worker simultaneously — unusual under normal load, possible during an analytics spike or MV refresh that leaked a connection.
NQ23: "After _lockedInsertSession commits and the function returns, does the advisory lock still exist?"
The trap: candidate thinks they need to explicitly release it.
Wrong answer: "Yes — you'd need to call pg_advisory_unlock() later."
Right answer: No. pg_advisory_xact_lock is transaction-scoped — it releases automatically at COMMIT or ROLLBACK. The finally { client.release() } block releases the pg.Pool connection back to the pool, but the lock was already released at COMMIT (which happened before client.release()). There is no pg_advisory_unlock() call because it would be redundant and could even cause an error (unlocking a lock that no longer exists). This is the entire point of choosing pg_advisory_xact_lock over pg_advisory_lock: the lifecycle matches the transaction, not the connection.
NQ24: "Your middleware uses NEXT_PUBLIC_APP_URL for redirect URLs instead of request.url. Why not just use request.url?"
The trap: candidate says it's just a configuration preference.
Wrong answer: "Either would work — request.url is more dynamic."
Right answer: Behind Nginx reverse proxy, request.url on the Next.js server contains the internal URL: http://127.0.0.1:3000/dashboard. Nginx proxies the request but the internal address is what Next.js sees. If middleware redirects to http://127.0.0.1:3000/login, the browser receives a redirect to an internal IP it cannot reach. NEXT_PUBLIC_APP_URL = https://attemptiq.com is always the external URL. Additionally, request.url may contain http:// when the original request was https:// — because Nginx terminates TLS and forwards plain HTTP internally. Redirecting from HTTPS to HTTP would trigger browser security warnings.
NQ25: "Your Redis client uses lazyConnect: false. What's the failure mode if you used lazyConnect: true instead?"
The trap: candidate says "lazy connection is more efficient."
Wrong answer: "Lazy connect is better — it only connects when needed."
Right answer: lazyConnect: false connects immediately when new Redis(...) is instantiated. If Redis is unreachable at startup, the error surfaces immediately — visible in PM2 startup logs, caught by the health check. With lazyConnect: true, the connection is deferred until the first command. If Redis is unreachable, the first user request that hits Redis fails with a connection error — from the user's perspective, a random request fails. Worse: the app reports as healthy (started successfully) but is silently broken. The health check pings Redis — but if the health check runs before any user request, it would also trigger the lazy connect and surface the error... eventually. lazyConnect: false makes startup failures loud and immediate, which is always better.
NQ26: "Two PM2 workers both try to refresh the same materialized view simultaneously. What happens?"
The trap: candidate says "one of them fails with an error."
Wrong answer: "PostgreSQL throws an error on the second refresh."
Right answer: REFRESH MATERIALIZED VIEW CONCURRENTLY acquires a ShareUpdateExclusiveLock. Two concurrent CONCURRENTLY refreshes on the same view — the second one blocks (waits) until the first completes. Then the second runs a full refresh from scratch (computing the new result, diffing, applying). So both refreshes complete, but sequentially. The second one's work is mostly redundant. This is why the MV_SCHEDULE in the BullMQ worker uses staggered cron times (0, 10, 20, 30, 40, 50 minutes past the hour) — different views don't conflict. For the same view, BullMQ's job scheduler ensures only one job per scheduler ID fires at a time, preventing this scenario.
NQ27: "Your submitSession checks WHERE is_submitted = false. But getFullSessionResult also checks session.is_submitted before returning results. Aren't you checking the same thing twice?"
The trap: candidate says yes, one is redundant.
Wrong answer: "Yes — we should remove one of the checks."
Right answer: The two checks protect against different things. submitSession's WHERE is_submitted = false is the idempotent guard — it prevents double-submission at the database level. If two concurrent requests both call submitSession, only one UPDATE matches rows and succeeds. The other gets rowCount = 0 → throws SessionAlreadySubmittedError. getFullSessionResult's if (!session.is_submitted) throw ForbiddenError is an access control check — it prevents users from fetching results before they've submitted (i.e., peeking at answers mid-test). These are fundamentally different: one is a concurrency guard, the other is an authorization gate.
NQ28: "Your ingestion pipeline commits every 50 questions. If question 43 in a batch fails validation, what happens to questions 1-42?"
The trap: candidate says "they're committed — the batch is atomic."
Wrong answer: "Questions 1-42 are in the database."
Right answer: It depends on implementation. If the batch uses one transaction for all 50: question 43's failure rolls back all 50. Questions 1-42 are NOT in the database. The batch goes to the JSONL failure file for --replay-failed. If the batch uses individual inserts with a commit every 50: question 43's failure is caught, it goes to the failure file, questions 1-42 are already committed. From ingestion/ingest.py: the pipeline uses a batch commit approach — questions are collected and inserted together, with failed questions written to JSONL regardless. The exact atomicity depends on whether the exception handling rolls back the entire batch or just the failing question. This is worth checking in the actual implementation.
NQ29: "Your getFullSessionResult calls this.questionsRepo.findAllByIds(questionIds) and this.questionsRepo.findAllByIdsWithAnswers(questionIds) as two separate queries. Both query the same questions table. Could PostgreSQL cache make the second query free?"
The trap: candidate says "yes, PostgreSQL caches results."
Wrong answer: "The second query would be served from PostgreSQL's cache."
Right answer: PostgreSQL does not cache query results. shared_buffers caches data pages (8KB blocks of table/index data), not query results. The second query touches the same heap pages as the first, which are now in shared_buffers (warm). So the second query skips disk I/O (pages already in RAM) but still runs the full query execution: parse, plan, executor, tuple decode. The benefit is page cache warmth — disk I/O is skipped. But query processing still runs. A result cache would require something like pg_query_cache extension (doesn't exist in vanilla PG) or application-level caching (Redis). The two-query design is still correct for security reasons (NQ11), and the warm page cache makes the second query fast.
NQ30: "You said output: 'standalone' reduces memory footprint ~60%. But the standalone build still requires Node.js to run. What exactly is it removing?"
The trap: candidate says "it removes unused JavaScript."
Wrong answer: "It tree-shakes the JavaScript bundles."
Right answer: output: 'standalone' traces which node_modules packages are actually imported by the server-side code at runtime, then copies only those into .next/standalone/node_modules. A typical node_modules directory is 500MB-1GB — it includes devDependencies (TypeScript compiler, ESLint, testing frameworks) that are never needed at runtime. The standalone build includes only the runtime dependencies. Additionally, it bundles the Next.js server into a single server.js file. The memory reduction is because Node.js loads fewer modules at startup (the module cache in memory is smaller) and the process's working set is smaller. The next start command loads the full Next.js framework from node_modules; node .next/standalone/server.js loads only what's needed.
NQ31: "Your rate limiter uses a sorted set with ${now}-${Math.random()} as the member. Why the random suffix — isn't the timestamp unique enough?"
The trap: candidate says "yes, timestamp should be unique."
Wrong answer: "The random suffix is unnecessary — timestamps are unique."
Right answer: Multiple requests can arrive within the same millisecond (Date.now() resolution is 1ms in JavaScript). If two requests arrive at timestamp 1704067200000, both would try ZADD key 1704067200000 "1704067200000". Redis sorted sets have unique members — the second ZADD would update the first entry's score rather than adding a new entry. The sorted set would show count=1 instead of count=2. The rate limiter would undercount by 1. Adding -${Math.random()} makes each member unique (probability of collision across 52 bits of randomness is negligible), ensuring each request is counted separately even within the same millisecond.
NQ32: "Better Auth's ba_users.id is TEXT, not a PostgreSQL UUID type. Your user_profiles.user_id references it as TEXT. What performance difference does this create?"
The trap: candidate says "TEXT vs UUID makes no difference for indexing."
Wrong answer: "It doesn't matter — both are stored the same way."
Right answer: PostgreSQL's native uuid type stores 16 bytes (128 bits) as binary internally. TEXT stores the UUID as 36 characters (e.g., 550e8400-e29b-41d4-a716-446655440000) — 36 bytes, 2.25x larger. For a table with millions of rows indexed on user_id, a native uuid index is 2.25x smaller than a TEXT index. Smaller index = more of it fits in shared_buffers = more cache hits = faster lookups. The practical impact at AttemptIQ's current scale is minimal. At 10 million user_attempts rows with a TEXT user_id index, the index is ~540MB vs ~240MB for native UUID. Not a crisis, but worth knowing.
NQ33: "Your Sentry tracesSampleRate: 0.1 samples 10% of transactions. Could you miss a production error that only happens in 5% of requests?"
The trap: candidate conflates error sampling with trace sampling.
Wrong answer: "Yes — if tracesSampleRate is 0.1, we might miss 90% of errors."
Right answer: tracesSampleRate controls performance tracing (distributed traces showing request timing, DB query spans, etc.), not error reporting. Unhandled exceptions are always captured — 100% of the time, regardless of tracesSampleRate. The 10% sampling means: only 10% of requests get a detailed performance trace attached. For an error that happens in 5% of requests: every single one generates a Sentry error event. You see all of them. What you might miss: the performance trace context (which DB query took long on that 5% of requests that fail). The error itself is never missed.
NQ34: "Your payment webhook verifies HMAC signature and returns 400 on failure. Why 400 and not 401 or 403?"
The trap: candidate says "401 means unauthenticated, 403 means forbidden — 401 is more correct."
Wrong answer: "We should return 401 — it's an authentication failure."
Right answer: 401 means "you need to authenticate — here's how" (requires a WWW-Authenticate header). 403 means "you are authenticated but not authorized." Neither fits a webhook: there's no concept of "logging in" for a webhook. 400 (Bad Request) communicates "the request body or headers are malformed" — which is correct: the HMAC signature header doesn't match the body. More importantly: returning 400 causes the payment gateway to NOT retry the webhook (payment gateways typically retry on 5xx, not 4xx). We want the gateway to stop retrying an invalid request, not keep hammering us. Returning 500 on invalid HMAC would trigger retries — wasted requests and potential DoS. 400 is correct.
NQ35: "You use COALESCE(NULLIF(dodo_session_id, 'pending_' || id::text), dodo_session_id) in the payment capture UPDATE. What is this doing?"
The trap: candidate says "this looks overly complex — there must be a simpler way."
Wrong answer: "I'm not sure — it seems like it could just be set to the new dodo_payment_id directly."
Right answer: When a payment is created, dodo_session_id is set to 'pending_' || id (a placeholder like pending_uuid-here) because we don't have a real Dodo session ID yet — Dodo generates it when the checkout is created, but we might re-create a checkout session without changing the payment row. On capture (webhook), we want to update dodo_session_id to the real value. NULLIF(dodo_session_id, 'pending_' || id::text) converts the placeholder to NULL. COALESCE(NULL, dodo_session_id) would keep the old value — but the outer COALESCE uses the real value or the current value. Actually the intent is: if the current dodo_session_id is the placeholder, replace it with the real session ID (from the checkout session). If it's already a real value, keep it. This avoids accidentally overwriting a real session ID with another real session ID on a retry.
NQ36: "You call void this.sendPaymentNotification(...).catch(err => ...) — the void keyword and .catch. Why void, and what happens if the catch itself throws?"
The trap: candidate says "void is unnecessary."
Wrong answer: "void is just a style choice — it doesn't do anything."
Right answer: void explicitly marks the promise as "intentionally not awaited." Without it, some linters (@typescript-eslint/no-floating-promises) would flag the unawaited promise as a potential swallowed error. void says "I know this is a promise and I'm not awaiting it on purpose." The .catch handles the notification failure (logs it, doesn't crash the payment flow). If the .catch callback itself throws an exception (which it shouldn't, since it just calls console.error), that exception becomes an unhandled rejection on the .catch promise — which Node.js logs and Sentry captures via the global unhandledRejection handler. It would not crash the process in modern Node.js (versions 15+), but it would be a Sentry event.
NQ37: "Your mockLimitGuard function for free users uses a lifetime count, but for paid users uses a monthly count. What happens when a free user upgrades to paid mid-month?"
The trap: candidate assumes the limit logic is applied at session creation only.
Wrong answer: "They'd get both limits — lifetime and monthly — applied simultaneously."
Right answer: The limit guard is determined by isFree at the moment of session creation. When a free user upgrades (plan_tier changes to 'monthly' or 'annual'), the next session creation call passes isFree: false to the service, which passes it to the repo, which uses the monthly limit guard. Past sessions (created when they were free) count toward the historical record but the monthly guard only counts sessions in the current month. So a user who used both free sessions in January, then upgrades in February: their February paid limit starts fresh from 0 (no sessions this month). They get the full monthly paid allowance. The free lifetime sessions don't interfere with the paid monthly count.
NQ38: "Your k6 load test uses sleep(1) and sleep(2) between requests. If you removed all the sleeps, would that give you a more realistic load test?"
The trap: candidate says "yes — real users don't sleep."
Wrong answer: "Yes — removing sleeps would show the true server capacity."
Right answer: Removing sleeps would make it a stress test (maximum throughput), not a load test (realistic concurrent load). Real users DO pause between requests — reading a question takes 30-120 seconds, thinking before answering. Without sleep, each VU fires requests as fast as the server can respond — essentially simulating bots, not students. The k6 load test with sleep(1-2) between requests simulates user think time. With 50 VUs each sleeping 3 seconds between 4 requests (7 seconds per iteration), the actual request rate is ~350 requests/minute — much lower than 50 simultaneous requests. This models realistic concurrent student load. Without sleep, 50 VUs would generate thousands of requests/minute — a stress test or DDoS simulation, not a realistic load profile.
NQ39: "Your _selectWeakAreaQuestions loops over subjects and runs one DB query per subject. You said N=2-3. But what if a user selects 10 subjects for a weak area session?"
The trap: candidate says "10 queries is still fine."
Wrong answer: "10 queries at 100ms each = 1 second — acceptable."
Right answer: The function signature accepts weakAreaSubjects?: string[] — user-selected subjects — which defaults to auto-detecting 3 weakest. The UI limits user selection. But the deeper issue: the session creation path is not rate-limited at the DB query level. If someone crafted a request with 20 subjects, the function would run 20 queries sequentially. This is an input validation gap. The Zod schema on the API route should enforce weakAreaSubjects.max(10) or similar. The service and repo currently trust that the number of subjects is reasonable. Fixing this: add z.array(z.string()).max(6) to the Zod schema for the mock session creation endpoint.
NQ40: "You have removeOnComplete: { count: 100 } and removeOnFail: { count: 500 } in BullMQ options. Why keep more failed jobs than completed jobs?"
The trap: candidate says "it's arbitrary."
Wrong answer: "It's just a default setting."
Right answer: Completed jobs are success confirmations — you need to see the last few to verify the system is running, but old successes have no debugging value. 100 entries is enough to confirm recent health. Failed jobs are debugging artifacts — you want more history because diagnosing why something failed often requires looking at multiple failures over time to spot patterns (is it always the same user? always the same time of day? always after a specific event?). 500 failed jobs gives you enough history to debug intermittent failures without consuming too much Redis memory. The ratio (5x more failures than completions) reflects the asymmetry in debugging value.
NQ41: "Your distributeProportionally function uses Math.max(100 - accuracy, 10) as the weakness score. Why floor at 10 instead of 0?"
The trap: candidate says "a user with 100% accuracy should get 0 questions."
Wrong answer: "A floor of 10 means 100%-accurate users still get questions — that seems wrong."
Right answer: If a user has 100% accuracy in subject X and 50% in subject Y, and we distribute 30 questions: with floor=0, X gets 0 questions (weakness score = 0), Y gets all 30. But the user might have only answered 5 questions in X — 100% accuracy on 5 questions is not a statistically reliable signal. With floor=10, X gets a minimum allocation (proportional to 10 vs 50), ensuring the user sees at least some questions from subjects they seem strong in. This prevents over-specialization on subjects the MV hasn't had enough data to characterize accurately. Also, minPerSubject = 3 ensures each selected subject gets at least 3 questions regardless of the score-based distribution.
NQ42: "You said UPSC exam content is served from the questions table with is_approved = true. What happens if an admin accidentally approves a question with a wrong correct_answer?"
The trap: candidate says "that's an admin error, not a system design issue."
Wrong answer: "Admin should be careful."
Right answer: This is a data integrity issue with real consequences — users see incorrect answers in post-test review, and their is_correct records in user_attempts are wrong (based on the incorrect correct_answer at submission time). The fix requires: (a) updating the correct_answer in questions, (b) finding all user_attempts where question_id = that question and is_correct is wrong, (c) updating those attempts, (d) triggering MV refresh. None of this is implemented as an admin feature currently — it's a manual DB operation. The right design: admin "unapprove" removes the question from future sessions but doesn't fix historical attempts (too complex). A separate "correction" flow that updates correct_answer and re-grades historical attempts atomically. This is a known gap in the admin panel.
NQ43: "Your sessions table doesn't have subject or question_count columns. How do you know how many questions are in a session or what subject it covers?"
The trap: candidate says "those columns must be in a different table."
Wrong answer: "Check the session_questions junction table."
Right answer: There is no junction table. sessions stores question_ids as a PostgreSQL array. question_count = array_length(question_ids, 1). For subject: practice sessions are single-subject (the subject is determined by querying the first question's subject, or by convention — all questions in a practice session have the same subject since they're selected WHERE q.subject = $2). The schema trades a question_count column for the array_length() function — no redundant data. It trades a subject column for query-time derivation from the questions. This is fine for practice sessions. For mock tests (multi-subject), there's no single "subject" — the test covers all subjects. This is correct — mock tests are intentionally multi-subject.
NQ44: "Your health check requires a HEALTH_SECRET query parameter for detailed output. But UptimeRobot stores the full URL including the secret. Is that secure?"
The trap: candidate says "yes — UptimeRobot is a trusted service."
Wrong answer: "It's fine — UptimeRobot keeps the URL private."
Right answer: The URL with the secret is stored in UptimeRobot's systems — a third-party SaaS. If UptimeRobot has a breach, the secret is exposed. An attacker with the secret can call /api/health?t=<secret> to see which specific infrastructure components are healthy or unhealthy — useful for timing attacks (attack when Redis is restarting, before the rate limiter is restored). A better approach: use a separate monitoring endpoint that accepts the secret via a request header (X-Health-Token), or use UptimeRobot's built-in HTTP auth (which stores credentials separately). Query parameters appear in server access logs too — the secret would be in every Nginx access log line for the health check. Log scrubbing would be needed.
NQ45: "Your API routes use Zod for input validation. Could a malicious request bypass Zod by sending a Content-Type: application/x-www-form-urlencoded body instead of JSON?"
The trap: candidate says "Zod validates all input types."
Wrong answer: "Zod handles any content type."
Right answer: Next.js App Router's request.json() only parses application/json bodies. If a request comes with Content-Type: application/x-www-form-urlencoded, request.json() returns a parse error (or empty object, depending on the body). Zod then validates that against the schema — and likely fails (the schema expects specific fields). The route returns a 400. So form-encoded bodies don't bypass Zod — they fail at the JSON parsing step. However, if a route uses request.formData() anywhere (it shouldn't), form-encoded input would be parsed but then hit Zod. The safe pattern: always use request.json() for API routes, never request.formData(). The Content-Type header is not validated — but the body parsing fails gracefully.
NQ46: "You run npm ci --production=false during deployment. Why --production=false specifically instead of just npm ci?"
The trap: candidate says "those are equivalent."
Wrong answer: "npm ci without flags is fine — it installs all dependencies."
Right answer: npm ci respects the NODE_ENV environment variable. If NODE_ENV=production is set (which it often is on production servers), npm ci behaves like npm ci --production — it skips devDependencies. But devDependencies include Tailwind CSS, PostCSS, and other build tools needed by npm run build. Skipping them causes the build to fail with "Cannot find module 'tailwindcss'" or similar. --production=false explicitly overrides NODE_ENV for the install step, ensuring devDependencies are installed even in production environments. After the build, Next.js's standalone output only includes runtime dependencies anyway — the devDependencies installed during build are not part of the running application.
NQ47: "Your user_attempts table has UNIQUE(user_id, question_id, session_id). Could a user answer the same question in two different sessions?"
The trap: candidate says "no — the unique constraint prevents it."
Wrong answer: "A user can only answer each question once ever."
Right answer: The unique key is (user_id, question_id, session_id) — all three together. If session A and session B both include question Q, the user can answer Q in session A (row: user_id, Q, session_A) AND answer Q in session B (row: user_id, Q, session_B). These are two different rows because session_id differs. The constraint only prevents answering the same question twice within the same session. This is intentional: a user might see the same question in a practice session and a mock test. Their attempts are tracked separately per session. The analytics materialized views aggregate across all sessions, so the user's overall accuracy on question Q reflects all attempts.
NQ48: "Your middleware checks for the aiq_ob cookie to gate onboarding. What prevents a user from just setting document.cookie = 'aiq_ob=1' in the browser console to skip onboarding?"
The trap: candidate says "they can't — the cookie is HttpOnly."
Wrong answer: "The HttpOnly flag prevents this."
Right answer: The aiq_ob cookie is NOT the session token — it's an onboarding flag. It may not be HttpOnly (it's set by the app for UX purposes, not for security). A user who sets aiq_ob=1 themselves skips the onboarding wizard. This is fine — onboarding is a UX flow that sets user preferences. A user who skips it just doesn't configure their preferences on first login. They can always reconfigure from settings. The cookie is not a security gate (no payment, no data access is gated on it). Treating it as a security control would be wrong — it controls UX, not authorization.
NQ49: "You mentioned session_recording: { maskAllInputs: true } in PostHog. Does this prevent PostHog from recording the exam questions and answer choices that appear as text on the screen?"
The trap: candidate says "yes — masking all inputs means all sensitive data is hidden."
Wrong answer: "maskAllInputs hides all text on the screen."
Right answer: maskAllInputs: true masks <input>, <textarea>, and <select> elements — form inputs where users type or select values. It does NOT mask text rendered in <p>, <div>, <span>, or other non-form elements. Exam questions and answer choices displayed as HTML text would be visible in PostHog session recordings. This could expose question content to anyone with PostHog admin access. For an exam prep product, this is a business sensitivity concern (question content is the core IP), not a user privacy concern. The fix: configure PostHog's maskTextFn to mask the practice/mock test route's question content, or disable session recording on exam pages entirely.
NQ50: "If you needed to add a new question type (e.g., fill-in-the-blank instead of multiple choice) in 2 days, what breaks first?"
The trap: candidate lists technical changes without identifying the hardest constraint.
Wrong answer: "I'd add a question_type column to the questions table and update the frontend."
Right answer: The hardest constraint is the correct_answer evaluation logic. Currently: isCorrect = selectedAnswer === question.correct_answer — a simple string equality check. For fill-in-the-blank: what counts as correct? Exact match? Case-insensitive? Partial credit? This is a product decision that cascades into: (1) the submitAttempt service's correctness logic, (2) the user_attempts schema (selected_answer is a TEXT column, fine for text input), (3) the MV aggregations (they aggregate is_correct booleans — if partial credit exists, the boolean model breaks), (4) the frontend rendering of question options, (5) the admin ingestion pipeline validation. The database schema change is trivial. The correctness semantics and downstream analytics model are the hard part. The 2-day deadline is tight because product decisions must be made first — the engineering work follows from those decisions.
The Tricky Fundamentals: Caching, Sessions, Redis, Security, APIs, Next.js
These are not trick questions in the nasty sense — they are the questions most developers answer confidently and incorrectly. The trap is always the same: the candidate knows the term but not the mechanism.
CACHE INVALIDATION
TC1: "You cache a user's analytics in Redis with a 10-minute TTL. The user completes a practice session. When does their cached analytics update?"
The trap: candidate says "immediately after submission."
Wrong answer: "The cache updates when the session is submitted."
Right answer: It doesn't update automatically at all. The cache holds a snapshot from the last cache miss. The analytics data in Redis stays stale until either: (a) the TTL expires (up to 10 minutes), or (b) you explicitly delete the key (redis.del(key)) on session submission. AttemptIQ doesn't explicitly invalidate the analytics cache on submission — it relies on TTL expiry. This means a user who submits a session and immediately checks analytics sees old data. The underlying MV also has its own staleness (2-hour refresh). So in the worst case: user submits at 10:01am, Redis cache was set at 10:00am (expires 10:10am), MV was last refreshed at 9:00am (next refresh at 11:00am) — user sees data from 9:00am at 10:01am. That's 61 minutes stale. Acceptable for exam prep analytics; unacceptable for real-time dashboards.
TC2: "You call revalidatePath('/analytics') from a server action. What exactly gets invalidated?"
The trap: candidate says "the analytics page cache and Redis cache both clear."
Wrong answer: "Everything cached for that URL is cleared."
Right answer: revalidatePath('/analytics') only invalidates Next.js's server-side fetch cache for that route. It does NOT invalidate: Redis (your application cache), Cloudflare's CDN cache, the browser's HTTP cache (Cache-Control headers still apply). It tells Next.js to re-run the server component on the next request instead of serving a cached render. If your analytics page reads from Redis and Redis still has stale data, revalidatePath does nothing for the staleness — the re-rendered page still reads the same stale Redis value.
TC3: "You set export const revalidate = 3600 on the pricing page. 200 users visit simultaneously when the page is stale. How many revalidation requests hit your server?"
The trap: candidate says "200 — one per user."
Wrong answer: "Every visitor triggers a revalidation."
Right answer: One. Next.js ISR uses a stale-while-revalidate model. When the first request arrives on a stale page: Next.js immediately serves the stale cached version to that user, then triggers exactly one background revalidation. All other simultaneous requests also get the stale version — from the same cached response. The one background revalidation finishes, updates the cache. The next request after that gets the fresh version. This is the "stale-while-revalidate" pattern: users always get a fast response (stale cache), and exactly one server render happens per revalidation cycle, not one per visitor.
TC4: "What's the difference between cache: 'no-store' and cache: 'no-cache' in Next.js fetch?"
The trap: candidate says "both skip the cache."
Wrong answer: "They both bypass caching — same behavior."
Right answer: no-store means: never cache this response, never read from cache. Every request goes to the origin. no-cache means: always revalidate with the origin before using a cached response (ETag / Last-Modified check). The origin can reply with 304 Not Modified — the cached response is used. With no-store, the cached response is never used. With no-cache, the cached response is used if the origin confirms it's still fresh. In practice for Next.js: no-store opts the route out of the Data Cache entirely (makes it dynamic). no-cache also makes it dynamic but allows conditional requests. For most authenticated API calls in AttemptIQ, no-store is the right choice — user-specific data should never be served stale.
TC5: "You have a Cloudflare-cached public page. An admin approves a new question that should appear on it. How does the cache clear?"
The trap: candidate says "it clears automatically."
Wrong answer: "Cloudflare detects the content changed and clears the cache."
Right answer: Cloudflare does not poll for content changes — it's a pull cache. Once a page is cached at Cloudflare's edge, it stays cached until the TTL expires or you send a purge request. If you don't purge, the new question doesn't appear until the Cloudflare TTL expires (1 hour). The correct flow: admin approves question → server calls Cloudflare's Cache Purge API (POST /zones/{zone_id}/purge_cache) for the affected URLs → Cloudflare discards the cached version → next request re-fetches from origin → fresh content is cached. Without this, you'd need to wait up to an hour for the cache to naturally expire.
TC6: "You SET key value EX 600 in Redis, do some work, then SET key newvalue. What is the TTL on the key now?"
The trap: candidate says "600 seconds from the original set."
Wrong answer: "The TTL continues from when it was first set."
Right answer: A plain SET key newvalue command resets the key entirely — including its TTL. The new key has no TTL (persists forever) unless you add EX again. After SET key newvalue, running TTL key returns -1 (no expiry). This is a common Redis bug: you cache a value with a TTL, later update the value with a plain SET (forgetting EX), and the key now lives forever — a Redis memory leak. The fix: always use SET key value EX 600 (or SETEX key 600 value) when updating a cached value with a TTL requirement.
TC7: "What is cache stampede, and does AttemptIQ have a mechanism to prevent it?"
The trap: candidate says "it's when too many users access the cache."
Wrong answer: "Cache stampede is when the cache fills up."
Right answer: Cache stampede (also called thundering herd) occurs when many cached keys expire simultaneously, and all concurrent requests find a cache miss and simultaneously query the database. Example: 500 users have their analytics cache expire at 10:10:00 exactly (they all logged in at 10:00:00 and got the same 10-minute TTL). At 10:10:00, all 500 users' next request is a cache miss → 500 simultaneous mv_user_subject_stats queries → PostgreSQL saturates. AttemptIQ does NOT currently have stampede prevention. The fix is TTL jitter: TTL = 600 + Math.floor(Math.random() * 60) — spreads expiries over a 60-second window, smoothing the load. This is a documented gap in the codebase.
SESSIONS
TC8: "A user logs out in browser tab 1. They switch to tab 2. Are they still logged in in tab 2?"
The trap: candidate says "yes — tab 2 still has the cookie."
Wrong answer: "Tab 2 still works until they make a request."
Right answer: Logout calls Better Auth's signOut endpoint, which deletes the session row from ba_sessions. The cookie still exists in the browser (both tabs share the same cookie jar). But the cookie contains a session token that no longer exists in the database. The next request from tab 2 that requires authentication calls Better Auth to validate — it looks up the session token in ba_sessions, finds nothing, returns "not authenticated." Tab 2 gets redirected to login. The redirect happens on the next server-validated request, not immediately — so tab 2 might appear to "still work" for a moment if the UI doesn't make a server call immediately.
TC9: "A cookie has Max-Age: 604800 (7 days). The user closes the browser. Are they still logged in when they reopen it?"
The trap: candidate says "no — session cookies clear when the browser closes."
Wrong answer: "Closing the browser clears the session."
Right answer: A cookie with Max-Age or Expires is a persistent cookie — it survives browser restarts. The session is still active when the browser reopens. A session cookie (no Max-Age or Expires) is stored in memory and cleared when the browser closes. Better Auth sets Max-Age: 604800 by default — users stay logged in for 7 days across browser restarts. This is intentional (convenience). The risk: if a user logs in on a shared computer and forgets to log out, their session persists for 7 days. The concurrent session limit (max 2) mitigates this by revoking old sessions when a new login happens.
TC10: "What's the difference between the expires and max-age attributes on a Set-Cookie header? Which takes priority?"
The trap: candidate says "they're equivalent."
Wrong answer: "Same thing — both set the cookie lifetime."
Right answer: expires is an absolute date: expires=Thu, 01 Jan 2026 00:00:00 GMT. max-age is a relative duration in seconds from now: max-age=604800. The difference matters because expires depends on the client's clock — if the client's clock is wrong (5 hours ahead), the cookie expires 5 hours early. max-age is relative to when the response was received — clock skew doesn't matter. When both are present, max-age takes priority in modern browsers (RFC 6265). Better Auth and most modern frameworks use max-age for this reason.
TC11: "Your session is stored in PostgreSQL (ba_sessions). Every authenticated request does a DB lookup. How does this scale to 10,000 req/sec?"
The trap: candidate says "it doesn't — you need JWTs."
Wrong answer: "Session-based auth can't scale — you need stateless JWTs."
Right answer: A single ba_sessions lookup is a primary-key lookup on an indexed column — approximately 1-2ms. At 10,000 req/sec, that's 10,000 session lookups/sec. With pgBouncer's 25 connections at 2ms each: 25 connections × 500 lookups/sec per connection = 12,500 lookups/sec. Already enough. With Redis session caching (token → user_id, TTL=5min): session lookups never hit PostgreSQL — Redis handles 100,000+ ops/sec. Session-based auth scales well; the claim that "JWTs are needed for scale" is a myth. JWTs are needed for stateless auth (multiple services, no shared DB). For a monolith with Redis, sessions scale fine.
REDIS
TC12: "Redis is single-threaded for command execution. How does it handle 100,000 operations per second without a multi-core advantage?"
The trap: candidate says "it can't — single-threaded is slow."
Wrong answer: "Redis is actually multi-threaded in newer versions."
Right answer: Single-threaded eliminates lock contention — the #1 overhead in concurrent systems. Every multi-threaded system spends significant CPU time on mutex acquisition, memory barriers, and cache coherence between cores. Redis avoids all of this. The single thread runs an event loop (epoll on Linux) that processes all I/O events efficiently. Most Redis commands are O(1) or O(log N) operations on in-memory data structures — they complete in microseconds. At 100µs per command, a single thread can process 10,000 commands/sec. With pipelining (multiple commands per network round-trip), throughput reaches 100,000-1,000,000 ops/sec. Redis 6.0+ uses I/O threads for network reads/writes (not for command execution), but command processing remains single-threaded.
TC13: "What's the difference between DEL key and UNLINK key in Redis? When does it matter?"
The trap: candidate says "they both delete the key — same thing."
Wrong answer: "UNLINK is just an alias for DEL."
Right answer: DEL deletes the key synchronously — the memory is freed before the command returns. For a key holding a large value (a sorted set with 100,000 members), DEL blocks the Redis event loop while it frees all that memory. During this time, all other commands wait. UNLINK deletes the key reference immediately (O(1)), then queues the actual memory freeing to a background thread. The command returns instantly, and the event loop continues processing other commands. For small keys, no difference. For large keys (BullMQ's job sets, large analytics caches), UNLINK prevents blocking. Use UNLINK by default; DEL only when you need synchronous confirmation that memory is freed.
TC14: "You store rate limit data in Redis sorted sets. These keys have TTL set. You also store BullMQ job data (no TTL). Your maxmemory-policy is volatile-lru. Redis hits its memory limit. What gets evicted?"
The trap: candidate says "LRU — least recently used keys."
Wrong answer: "The least recently used keys get evicted regardless of type."
Right answer: volatile-lru only evicts keys that have a TTL set (volatile keys). Rate limit sorted sets have TTL (set via EXPIRE in the rate limiter pipeline). BullMQ job keys have no TTL. When Redis hits maxmemory, it evicts rate limit keys (the least recently used ones among TTL-bearing keys), never BullMQ job keys. This is intentional — BullMQ job loss is catastrophic (silent work loss), while rate limit eviction is acceptable (brief gap in rate limiting). If the policy were allkeys-lru, BullMQ keys would be evicted under memory pressure — a production incident waiting to happen.
TC15: "You pipeline 4 Redis commands together. The third command fails. What happens to commands 1, 2, and 4?"
The trap: candidate says "the whole pipeline rolls back."
Wrong answer: "It's atomic — either all succeed or all fail."
Right answer: A Redis pipeline is not a transaction. It's a batching mechanism — all commands are sent in one TCP write, reducing round-trips. If command 3 fails (e.g., wrong type operation), commands 1, 2, and 4 still execute. Redis returns the results of each command individually — command 3's result is an error, the others are successes. pipeline.exec() returns an array of [error, result] pairs. You must check each one. For the rate limiter, this is fine — if zadd fails but zremrangebyscore and zcard succeeded, the rate count might be off by 1 for that request. Acceptable. For write operations where atomicity matters, use MULTI/EXEC (Redis transactions), not pipelines.
TC16: "Your Redis key is rl:waitlist:1.2.3.4. You call EXPIRE key 61. The key already had TTL=30. What is the new TTL?"
The trap: candidate says "the TTLs add together."
Wrong answer: "The new TTL is 30 + 61 = 91 seconds."
Right answer: EXPIRE replaces the existing TTL — it sets the TTL to 61 seconds from now, regardless of the previous value. TTLs don't accumulate. In the rate limiter pipeline, pipeline.expire(key, windowSeconds + 1) is called after every request. This resets (extends) the TTL to windowSeconds + 1 from the current moment. This is intentional: the key should live as long as the window plus a small buffer. Without resetting the TTL, a key created at 12:00:00 with TTL=61 would expire at 12:01:01 even if the user makes requests at 12:01:00 that should still be counted in the window.
SECURITY
TC17: "Your API error response says: { error: 'User with email alice@gmail.com not found' }. What's the security problem?"
The trap: candidate says "error messages should be friendlier."
Wrong answer: "It's just a UX issue."
Right answer: This is user enumeration — an attacker can determine whether an email address is registered by checking the error message. Send POST /api/auth/login with alice@gmail.com → "User not found" → alice is NOT registered. Send with bob@gmail.com → "Invalid password" → bob IS registered. An attacker can harvest valid email addresses from your system by probing with known emails. These valid emails are then targets for credential stuffing, phishing, or brute force. The fix: return the same generic error for both "user not found" and "wrong password": { error: 'Invalid email or password' }. Never reveal which part of the credential check failed.
TC18: "Your API returns 404 when a session ID exists but belongs to a different user. Is this correct, or should it return 403?"
The trap: candidate says "403 — the user is authenticated but not authorized."
Wrong answer: "It should be 403 — you know the resource exists."
Right answer: 404 is correct — deliberately. Returning 403 confirms that the resource exists (but you can't access it). An attacker probing UUIDs to find valid session IDs learns: 404 = doesn't exist OR doesn't belong to you. They learn nothing useful. 403 = exists but not yours. Combined with incremental UUID guessing (infeasible, but theoretically possible), 403 leaks more information than 404. For any user-owned resource (sessions, payments, attempts), return 404 when ownership check fails — treat "not yours" the same as "doesn't exist." This is a form of authorization-through-obscurity that is legitimate when combined with proper auth (you can't enumerate other users' IDs without guessing random UUIDs).
TC19: "You add Content-Security-Policy: default-src 'self' as a security header. What does this prevent, and what does it NOT prevent?"
The trap: candidate says "it prevents all XSS attacks."
Wrong answer: "CSP prevents XSS."
Right answer: CSP prevents the browser from executing inline scripts and loading resources from external domains. It mitigates XSS by restricting what scripts can run — even if an attacker injects <script>alert(1)</script>, the browser blocks it (inline scripts violate default-src 'self'). What CSP does NOT prevent: (1) XSS via allowed origins (if you whitelist https://cdn.example.com, scripts from there are allowed — a compromised CDN bypasses CSP). (2) DOM-based XSS that doesn't involve <script> tags (e.g., innerHTML assignments from untrusted data). (3) Server-side injection into HTML (that's not the browser's concern). (4) CSRF (CSP controls resource loading, not request sending). CSP is one layer in a defense-in-depth stack, not a complete XSS solution.
TC20: "Your NEXT_PUBLIC_SENTRY_DSN is in the public env (prefixed with NEXT_PUBLIC_). Is it safe to expose this in client-side JavaScript?"
The trap: candidate says "no — DSNs are secrets."
Wrong answer: "This is a security vulnerability — remove the NEXT_PUBLIC_ prefix."
Right answer: Sentry DSNs are intentionally public. They're designed to be embedded in client-side JavaScript because Sentry needs the DSN to send error reports from the browser. Anyone can see the DSN in your page source. The worst an attacker can do with your DSN is send fake error events to your Sentry project, which might: (a) fill up your Sentry quota, (b) generate alert noise. Sentry mitigates this: DSN-based rate limiting, inbound data filtering, and project-level rules. The DSN is not a secret — it's more like a public endpoint identifier. Your SENTRY_DSN (server-side, no NEXT_PUBLIC_) for server error reporting might have slightly different concerns (someone could flood your server-side quota), but it's still not a credential that grants access to your systems.
TC21: "You validate all API inputs with Zod. Can a user still cause a SQL injection attack?"
The trap: candidate says "no — Zod prevents SQL injection."
Wrong answer: "Zod prevents SQL injection by validating input types."
Right answer: Zod prevents invalid types and shapes from reaching the business logic. SQL injection prevention comes from parameterized queries — db.query('SELECT ... WHERE id = $1', [userId]). Zod validates that userId is a string of the right format. The parameterized query ensures that value is sent as a data parameter, not interpolated into the SQL string. You need both: Zod rejects garbage inputs early (fast fail, typed errors), parameterized queries prevent injection even if somehow a malicious value passes Zod. They're complementary, not redundant. If you ever built SQL by string concatenation (WHERE id = '${userId}'), Zod would NOT protect you — a string like '; DROP TABLE users; -- passes .string() validation.
TC22: "A user resets their password. Do their existing sessions get invalidated? Should they?"
The trap: candidate says "yes, automatically."
Wrong answer: "Sessions invalidate automatically on password change."
Right answer: This depends entirely on Better Auth's implementation. Session tokens are random values stored in ba_sessions — they don't contain or reference the password hash. Changing the password does NOT automatically invalidate sessions. An attacker who stole a session token continues to have access even after the victim changes their password. The fix: on password change, explicitly revoke all existing sessions for that user (DELETE FROM ba_sessions WHERE user_id = $1). Better Auth may or may not do this by default — it must be verified. For an exam prep app, this is a significant security gap: if a user's account is compromised and they change their password to "kick out" the attacker, the attacker's session remains active.
APIS
TC23: "Your API route doesn't have export const dynamic = 'force-dynamic', but it reads cookies(). What does Next.js do?"
The trap: candidate says "it throws an error."
Wrong answer: "You need to add force-dynamic manually for it to work."
Right answer: Next.js App Router automatically marks a route as dynamic when it detects dynamic functions: cookies(), headers(), searchParams, Date.now(), Math.random(). Reading cookies() without explicitly setting force-dynamic still works — Next.js infers it. The route renders dynamically on every request. export const dynamic = 'force-dynamic' is explicit documentation of intent, not a required flag. However: if you use cookies() in a Server Component nested inside a statically generated page, the entire page's rendering behavior changes from static to dynamic — potentially breaking ISR without any obvious error. This silent opt-out of static generation is the real gotcha.
TC24: "What happens if an API route returns Response.json(data) without setting Cache-Control headers? Is the response cached?"
The trap: candidate says "no — API routes are never cached."
Wrong answer: "API routes are always dynamic — no caching."
Right answer: By default, Next.js App Router does NOT cache API routes (they're dynamic). But Cloudflare and the browser can cache based on the absence of Cache-Control headers. Without explicit Cache-Control: no-store, Cloudflare might cache a GET response if it considers it cacheable (depends on Cloudflare settings). The browser might also cache it based on heuristic caching rules. For API routes returning user-specific data, always set: Cache-Control: no-store, no-cache. For API routes returning public data that can be cached (e.g., question list), set: Cache-Control: public, max-age=3600. Leaving it unset is an implicit decision you may regret.
TC25: "What's the difference between a 401 and a 403 in AttemptIQ's API? When does each appear?"
The trap: candidate says "401 = wrong password, 403 = no permission."
Wrong answer: "401 is authentication failure, 403 is authorization failure."
Right answer: Technically correct but the nuance matters. 401 means "you are not authenticated — authenticate and try again." The response should include a WWW-Authenticate header. AttemptIQ returns 401 when there's no valid session (no cookie, or session token not in ba_sessions). 403 means "you are authenticated, but you lack permission for this resource." AttemptIQ returns 403 when a session is valid but the resource belongs to a different user, or when the action is not allowed for the user's plan tier. The practical implication: a 401 should prompt the client to redirect to login. A 403 should show "access denied" — redirecting to login would be confusing since the user IS logged in.
TC26: "You return an empty array [] for a 'no results' response vs a 204 No Content. Which is correct for a list endpoint?"
The trap: candidate says "204 — more semantically correct."
Wrong answer: "204 No Content signals there are no results."
Right answer: 204 No Content means "the action succeeded and there is no response body" — it's appropriate for DELETE or actions with no return value. For a list endpoint (GET), [] with 200 is correct: the request succeeded, the result is an empty list. 204 would force the client to special-case the empty state (check status code instead of checking data.length). Returning 200 + [] means the client code is uniform: always parse the response, always check length. 204 also cannot have a response body by definition — you can't include pagination metadata or an empty array with it.
TC27: "Your API uses POST /api/sessions to create a session. A network glitch causes the response to be lost. The client retries. What happens?"
The trap: candidate says "a duplicate session is created."
Wrong answer: "The retry creates two sessions — the client should check first."
Right answer: The session limit guard prevents it. A free user (limit=2) who already has 1 session: retry creates session #2. Fine — they get 2 sessions as expected. A free user who already has 2 sessions: the retry's INSERT WHERE count < 2 fails (count is already 2). Returns null → SessionLimitReachedError → 409 response. The client receives an error, not a silent duplicate. The atomic INSERT guard provides idempotency-like behavior for the limit case. However, there's still a race: if the first request succeeded (session created) but the response was lost, the client thinks it failed and the user thinks they lost a session. The client should detect SessionLimitReachedError and respond "your session may already exist — check your active sessions" rather than "try again later."
NEXT.JS / REACT / FRONTEND
TC28: "What's the difference between a Server Component and a Client Component in Next.js App Router? What can each NOT do?"
The trap: candidate says "Server Components run on the server, Client Components in the browser."
Wrong answer: "Server Components = backend, Client Components = frontend."
Right answer: Server Components render on the server and their HTML is sent to the browser. They cannot: use React hooks (useState, useEffect), use browser APIs (window, localStorage), attach event handlers (onClick), or import Client Components that do any of these (unless wrapped in a boundary). Client Components are pre-rendered on the server during SSR (initial HTML) AND hydrated on the browser. They cannot: directly access fs, crypto, database clients, or other Node.js-only modules. The key insight: Client Components DO run on the server (for initial SSR HTML) — they just also run on the client. The distinction is about what JavaScript bundle is sent to the browser. Server Component code never appears in the client bundle — that's why they can safely import DB clients.
TC29: "You add 'use client' to a component that only renders static HTML (<div><p>Hello</p></div>). What's the performance cost?"
The trap: candidate says "none — it's just a directive."
Wrong answer: "No performance cost — the output is the same."
Right answer: Three costs: (1) The component's code is included in the JavaScript bundle sent to the browser (even if it doesn't use any browser APIs). (2) React hydrates the component on the client — attaching React's virtual DOM tracking to the static HTML, comparing server vs client render, reconciling. (3) Any components imported by this 'use client' component also become client components (the client boundary propagates down). For a static HTML component, this is pure overhead with zero benefit. The component would render identically as a Server Component with no hydration cost. The correct rule: only add 'use client' when the component actually needs browser APIs, event handlers, or React state. Otherwise, keep it a Server Component.
TC30: "What is hydration mismatch and when does it happen in Next.js?"
The trap: candidate says "it happens when server and client render different HTML."
Wrong answer: "Hydration mismatch is when the server crashes during SSR."
Right answer: Hydration mismatch occurs when React's client-side reconciliation finds that the HTML generated on the client (during hydration) differs from the HTML the server sent. React expected to "reuse" the server HTML but finds it doesn't match what it would render. Common causes: (1) Date.now() or Math.random() in a component — different values on server vs client. (2) typeof window !== 'undefined' conditions — server skips the branch, client executes it. (3) Browser extensions that modify the DOM before React hydrates. (4) localStorage access on first render — doesn't exist on server. The result: React throws a warning and re-renders from scratch on the client — defeating SSR's purpose (the user sees a flash or layout shift). Fix: use useEffect for client-only logic, suppressHydrationWarning for truly unavoidable cases.
TC31: "What is the difference between useEffect(() => {}, []) and useLayoutEffect(() => {}, []) in React? When does it matter?"
The trap: candidate says "useLayoutEffect is for layout calculations."
Wrong answer: "They run at the same time — useLayoutEffect is just synchronous."
Right answer: Both run after the component mounts. The timing difference: useEffect runs asynchronously after the browser has painted. useLayoutEffect runs synchronously after DOM mutations but before the browser paints. The user-visible difference: if useEffect reads DOM dimensions and updates state, there's one paint with the initial state, then another after the effect runs — a visible flash. useLayoutEffect updates state before the first paint — no flash. When it matters: DOM measurement (position, size), animations that must start from a known DOM state, anything that needs to prevent a visible flash of unstyled or mispositioned content. In Next.js SSR: useLayoutEffect runs on the server too (unlike in Create React App), but Next.js suppresses the server-side useLayoutEffect warning.
TC32: "You have a Next.js page with export const revalidate = 60. You deploy a new version of the app. The old cached page still serves for up to 60 seconds. Is this a problem?"
The trap: candidate says "yes — users see outdated HTML with old JavaScript references."
Wrong answer: "Old cached pages reference old JS bundles that no longer exist after deploy — 404s on assets."
Right answer: This IS a real problem in some setups. Next.js uses content-hashed filenames for JS/CSS chunks (page-[hash].js). After deploy, the old hash files may no longer exist on the server (if the build directory was replaced). An old ISR-cached HTML page references page-abc123.js — the new build has page-def456.js. User visits cached page → browser requests page-abc123.js → 404. Next.js mitigates this by keeping old build outputs for a period (output: 'standalone' handles this). But with PM2 and a file-based build replacing the previous build, old chunks can be deleted. Fix: keep the previous build's .next directory until all ISR caches have naturally expired, or use revalidate = 0 (force-dynamic) after deploys and re-enable ISR after.
TC33: "What is the difference between SSG, SSR, and ISR in Next.js? When does AttemptIQ use each?"
The trap: candidate gives textbook definitions without tying to actual use.
Wrong answer: "SSG = build time, SSR = request time, ISR = both."
Right answer: SSG (Static Site Generation): HTML generated at build time. Zero server load per request. Used for: public marketing pages (homepage, about, features) — content doesn't change per user or frequently. SSR (Server-Side Rendering): HTML generated fresh on every request. Used for: authenticated dashboard, practice session, analytics — user-specific data, must not be cached across users. ISR (Incremental Static Regeneration): HTML generated at build time, refreshed in the background on a schedule. Used for: public question bank pages, syllabus pages, SEO content — changes infrequently but is user-independent. The critical rule: any page that calls cookies(), headers(), or reads searchParams in a Server Component is automatically SSR — even if you intended ISR. AttemptIQ's public pages are ISR/SSG; all authenticated routes are SSR.
TC34: "Your Server Component fetches user data. A nested Client Component also needs that same user data. How do you share it without fetching twice?"
The trap: candidate says "fetch in both places — or lift state up."
Wrong answer: "The Client Component needs its own fetch because it can't access server data."
Right answer: Pass the data as props from the Server Component to the Client Component. Server Components can pass serializable data (objects, arrays, strings, numbers — not functions or class instances) as props to Client Components. The Server Component fetches once, passes the result down:
// Server Component
const userData = await db.query('SELECT ...', [userId]);
return <ClientDashboard user={userData} />;
// Client Component receives as props — no second fetch
'use client';
export function ClientDashboard({ user }) { ... }
This is the correct data flow. The alternative — fetching in the Client Component via useEffect — means the user sees a loading state after the initial page load (data arrives after hydration). Passing from Server Component means data is in the initial HTML — no loading state.
TC35: "What does Suspense do in Next.js App Router? Is it only for loading states?"
The trap: candidate says "yes — it shows a fallback while data loads."
Wrong answer: "Suspense is just for loading spinners."
Right answer: Suspense in App Router does three things: (1) Streaming: Server Components wrapped in Suspense are streamed to the client as they resolve — the shell of the page sends immediately, data-heavy sections stream in when ready. This improves Time to First Byte. (2) Parallel rendering: Multiple Suspense boundaries resolve independently — a slow analytics section doesn't block a fast question list section from rendering. (3) Loading UI: Shows fallback while the boundary is resolving. Without Suspense, all Server Components must resolve before any HTML is sent (waterfall). With Suspense boundaries, the page progressively fills in. For AttemptIQ's analytics page (multiple expensive queries), multiple Suspense boundaries mean the user sees the page structure immediately, with each analytics card filling in as its query resolves.
TC36: "You call router.push('/dashboard') inside a useEffect with an empty dependency array. What's the problem?"
The trap: candidate says "nothing — that's the standard way to navigate."
Wrong answer: "This navigates correctly after mount."
Right answer: Two issues: (1) In React 18 + Next.js with Strict Mode enabled (default in development), effects run twice on mount (mount → unmount → mount). router.push fires twice — the second push may interrupt the first navigation or cause unexpected behavior. (2) router.push inside useEffect creates a navigation that happens after hydration — the user briefly sees the non-redirected page before being pushed away. For authentication redirects (user is not logged in → push to login), this creates a flash of protected content. The correct approach: handle auth redirects in middleware (server-side, before any HTML is sent) or in a Server Component that renders a redirect response. router.push in effects is for user-triggered navigation (button click, form submit), not for auth gating.
TC37: "What is the difference between loading.tsx and a Suspense boundary in Next.js App Router?"
The trap: candidate says "they're the same — just different ways to show loading."
Wrong answer: "loading.tsx is for the full page, Suspense is for parts of it."
Right answer: loading.tsx is syntactic sugar for wrapping the entire page.tsx in a Suspense boundary. Next.js automatically creates the Suspense wrapper with <Loading /> as the fallback. The behavioral difference: loading.tsx activates on navigations (when you router.push to a route, the previous page renders immediately and loading.tsx shows in the new route's slot while the page loads). Suspense boundaries work during streaming SSR (server-side progressive rendering). They serve different phases: loading.tsx is for client-side navigation loading states, Suspense is for server-side streaming. You can have both: loading.tsx for the full-page navigation loading, and nested Suspense boundaries for streaming granular sections within the page.
TC38: "Your ISR page generates at build time and includes new Date().toLocaleDateString(). What date does a user see 6 months after the last build?"
The trap: candidate says "today's date."
Wrong answer: "The component re-renders on each visit."
Right answer: The date from the last time the page was regenerated — which could be 6 months ago at build time, or more recently if ISR revalidated it. ISR regenerates the page in the background when the revalidation period expires AND a user visits. The new Date() in a Server Component runs at render time — which for a static page is build time (or ISR revalidation time), not visit time. A user visiting today sees the date from the last ISR regeneration. If revalidate = 86400 (1 day) and no user visited in a week, the cached date is a week old. Fix: don't use new Date() in static/ISR pages if you need the current date — use export const dynamic = 'force-dynamic' or move the date display to a Client Component that reads new Date() after hydration.
TC39: "What is React Server Components' effect on bundle size? Give a concrete example from AttemptIQ."
The trap: candidate says "Server Components are smaller because they don't include React."
Wrong answer: "Server Components reduce bundle size by removing React hooks."
Right answer: Server Components are not included in the JavaScript bundle at all — their code never ships to the browser. Only their rendered HTML output is sent. In AttemptIQ: the sessionsService imports pg (PostgreSQL client), Better Auth, and repository classes. If the dashboard component that displays session data were a Client Component, all those imports would be in the JavaScript bundle. As a Server Component, zero of that code ships to the browser — only the rendered HTML. The user's browser downloads 0 bytes for the database client code. A concrete estimate: pg alone is ~50KB. Better Auth is ~100KB. Server Components eliminate these from the browser bundle entirely. This is why Next.js App Router's "zero client JS for static pages" claim is meaningful.
TC40: "You have a Client Component that calls fetch('/api/user') on every render. What performance issue does this create and how do you fix it?"
The trap: candidate says "use useCallback to memoize the fetch."
Wrong answer: "Wrap the fetch in useCallback."
Right answer: Calling fetch in a component body (not in useEffect) means it fires on every render — every state change, every parent re-render causes a fetch. useCallback memoizes a function reference, not its invocation — the fetch still runs on every render. The fixes: (1) Move the fetch into useEffect with an appropriate dependency array — fires only when dependencies change. (2) Use React Query / SWR — they handle deduplication, caching, and re-fetching declaratively. (3) Make it a Server Component — fetch happens once on the server, no client-side fetch at all. (4) Use Next.js use() hook with a Promise — but this requires Suspense boundary. The correct choice depends on staleness requirements: Server Component for data that can be stale until next navigation; SWR/React Query for data that needs client-side refresh; useEffect for imperative one-time fetches.
SSR / ISR / EDGE / RENDERING
TC41: "Next.js has 4 rendering modes: SSG, SSR, ISR, and Partial Prerendering (PPR). Without PPR, can you mix static and dynamic sections on the same page?"
The trap: candidate says "no — a page is either static or dynamic."
Wrong answer: "The whole page must be one mode."
Right answer: Yes — with Suspense boundaries. The page shell (header, navigation, static sections) can be statically generated, while dynamic sections (user-specific data) are wrapped in Suspense and streamed. Without PPR: the entire page is either static or dynamic based on whether any component in the tree uses dynamic functions. With Suspense + streaming: the static shell renders immediately (from cache), and Suspense boundaries stream dynamic content as it resolves server-side. PPR (Partial Prerendering, experimental in Next.js 14) formalizes this: the static shell is generated at build time and served from CDN instantly, while Suspense holes are filled by server-side streaming. AttemptIQ doesn't use PPR (experimental), but the Suspense streaming approach achieves similar benefits.
TC42: "Your middleware runs on the Edge Runtime. What does 'Edge Runtime' mean, and what Node.js APIs are unavailable?"
The trap: candidate says "Edge Runtime is just a faster version of Node.js."
Wrong answer: "Edge Runtime has the same APIs as Node.js — it's just deployed closer to users."
Right answer: Edge Runtime is a subset of the Web APIs standard, running on V8 isolates (like Cloudflare Workers). It is NOT Node.js. Missing APIs: fs (no file system), net (no raw TCP), pg (PostgreSQL client requires net), crypto module differs (Web Crypto API available instead), process.env is available but limited, no require() (only ESM). Available: fetch, Request, Response, Headers, URL, TextEncoder, crypto.getRandomValues(), crypto.subtle. This is why AttemptIQ's middleware only reads cookies (available) and does redirects (available) — no DB access. Any attempt to import pg or ioredis in middleware would fail at build time with "module not found in Edge Runtime."
TC43: "Two users visit your ISR page simultaneously for the first time after it goes stale. Does the page rerender twice?"
The trap: candidate says "yes — one render per user."
Wrong answer: "Both users trigger a regeneration."
Right answer: No. Next.js ISR uses a lock/deduplication mechanism. When the first request finds the page stale, it initiates one background regeneration and serves the stale version. Any concurrent request that also finds the page stale gets the stale version immediately — the regeneration lock prevents a second parallel regeneration. When the regeneration completes, all subsequent requests get the fresh version. If 1,000 users simultaneously hit a stale page: all 1,000 get the stale version instantly, one background regeneration runs, all subsequent requests get the fresh version. This is the correct behavior — you never want N simultaneous regenerations for N concurrent visitors.
TC44: "You use React.memo() on a component. The parent re-renders. Does React.memo() always prevent the child from re-rendering?"
The trap: candidate says "yes — that's the whole point of React.memo."
Wrong answer: "React.memo prevents re-renders unconditionally."
Right answer: React.memo does a shallow equality check on props. It prevents re-render only if all props are shallowly equal to the previous render. Three cases where it still re-renders: (1) Object/array props: <Memo items={[1,2,3]} /> — a new array literal is created on every parent render. Shallow comparison: [1,2,3] !== [1,2,3] (different references). Re-render happens. Fix: useMemo the array. (2) Function props: <Memo onClick={() => {}} /> — new function reference every render. Fix: useCallback. (3) Context: if the component consumes a context that changed, it re-renders regardless of memo. React.memo only checks props, not context. For AttemptIQ's question display components (potentially rendered 100 times in a session results list), memoization with stable props can prevent expensive re-renders.
TC45: "What is the difference between 'use client' and 'use server' directives in Next.js App Router?"
The trap: candidate says "use server is for Server Components."
Wrong answer: "'use server' marks a Server Component."
Right answer: 'use client' marks a Client Component boundary — all components in that file and their imports become Client Components. It's a file-level directive. 'use server' does NOT mark a Server Component — Server Components are the default (no directive needed). 'use server' marks a Server Action — a function that can be called from Client Components but executes on the server (form submissions, mutations). It can be placed inside a Server Component file (marking a specific function) or at the top of a file (marking all exports as server actions). Confusion between these two is a common Next.js 14 mistake: you don't write 'use server' at the top of a Server Component file — that would make it a file of server actions, not a server component.
TC46: "A Server Component fetches data that takes 3 seconds. What does the user's browser show during those 3 seconds?"
The trap: candidate says "a blank white screen."
Wrong answer: "The browser shows nothing until the server responds."
Right answer: Depends on how the component is structured. Without Suspense: the entire page waits 3 seconds before any HTML is sent (TTFB = 3s). The browser shows a blank screen. With a Suspense boundary wrapping the slow component: Next.js streams the page shell (header, nav, layout) immediately, sends the Suspense fallback (loading spinner/skeleton), then streams the resolved content when the 3-second fetch completes. The user sees something in ~100ms (the shell), not 3 seconds. This is Next.js App Router's streaming SSR. The 3-second fetch happens on the server — the user's network latency to the server is added on top. For AttemptIQ's analytics page with multiple slow queries, each section is wrapped in Suspense so the page feels responsive even when individual sections are slow.
TC47: "What does next/image do that a plain HTML <img> tag doesn't?"
The trap: candidate says "it's just lazy loading."
Wrong answer: "It adds loading='lazy' automatically."
Right answer: Five optimizations: (1) Format conversion: serves WebP or AVIF (smaller than JPEG/PNG) to browsers that support it — same image, 30-70% smaller file. (2) Responsive sizing: generates multiple sizes (srcset), browser downloads only the size matching the device's viewport — no downloading a 2000px image for a 400px container. (3) Lazy loading: yes, loading='lazy' by default (except above-the-fold images with priority). (4) Layout shift prevention: requires width and height props or fill — the browser reserves space before the image loads, preventing Cumulative Layout Shift (CLS). (5) On-demand optimization: images are processed and cached at request time by Next.js's image optimization API — no build-time preprocessing. For exam prep with hundreds of question images, next/image can cut image payload by 50%+.
TC48: "What is the difference between <Link href='/practice'> and router.push('/practice') in Next.js?"
The trap: candidate says "Link is declarative, router.push is imperative — same result."
Wrong answer: "They navigate the same way — just different syntax."
Right answer: Four differences: (1) Prefetching: <Link> prefetches the linked page's JavaScript bundle when it enters the viewport (in production). router.push does not prefetch — the next page's code loads only when navigation is triggered. (2) Accessibility: <Link> renders an <a> tag — keyboard navigable, screen reader compatible, right-click → "open in new tab" works. router.push typically lives in an onClick handler on a <button> or <div> — not naturally keyboard/screen-reader navigable unless you add ARIA attributes. (3) Crawlability: <Link> produces an <a href> in the HTML — search engine crawlers follow it. router.push is JavaScript-only — crawlers may not execute it. (4) Middleware: Both trigger Next.js middleware on navigation. The practical rule: use <Link> for all navigable links. Use router.push only for programmatic navigation after an action (form submit, delete confirmation).
TC49: "Your Next.js app uses fetch inside a Server Component without any cache option. Is this request cached?"
The trap: candidate says "no — fetch without cache options is always live."
Wrong answer: "No cache option means no caching."
Right answer: In Next.js App Router, fetch inside Server Components is extended to cache by default: cache: 'force-cache' is the default. This means the first request fetches from the origin and caches the result indefinitely (until the next deployment or manual revalidation). Every subsequent request — even from different users — gets the cached response. For an external API endpoint that returns user-specific data (e.g., fetch('/api/user')), this would return the first user's data to every subsequent visitor — a serious bug. You MUST explicitly set cache: 'no-store' for any user-specific or dynamic data. This is the most common Next.js App Router gotcha for developers coming from Pages Router where fetch always hit the network.
TC50: "What is 'prop drilling' and how does Next.js App Router reduce it compared to Pages Router?"
The trap: candidate says "use Context API — same in both."
Wrong answer: "Context API solves prop drilling in both."
Right answer: Prop drilling is passing data through many component layers just to reach a deeply nested component. In Pages Router, data from getServerSideProps arrives as props to the page component, which must drill it down to wherever it's needed — or you lift to Context. In App Router, any Server Component in the tree can directly fetch the data it needs — no drilling required. A deeply nested <UserBadge> Server Component can do const user = await getUser() directly without receiving user as a prop from the page. Each component fetches what it needs independently. Next.js deduplicates identical fetch calls within a render tree (request memoization) — so getUser() called in 5 different components only makes one actual network/DB request. Context is still needed for sharing mutable state in Client Component trees.
TC51: "You read process.env.DATABASE_URL in a Client Component. What value does the browser see?"
The trap: candidate says "the database URL — it's an env variable."
Wrong answer: "The browser gets the full DATABASE_URL string."
Right answer: The browser sees undefined. In Next.js, only environment variables prefixed with NEXT_PUBLIC_ are embedded in the client-side JavaScript bundle at build time. DATABASE_URL (no prefix) is only available server-side. At build time, Next.js replaces process.env.NEXT_PUBLIC_FOO with the actual value in the client bundle. process.env.DATABASE_URL in a Client Component is replaced with undefined. This is a deliberate security measure — DATABASE_URL contains database credentials. Exposing it in the client bundle would send your DB credentials to every visitor's browser. If you accidentally read a server secret in a Client Component and it shows undefined at runtime, this is why.
TC52: "What is generateStaticParams in Next.js App Router and what happens if a user visits a path you didn't generate?"
The trap: candidate says "they get a 404."
Wrong answer: "Only pre-generated paths work — others return 404."
Right answer: generateStaticParams tells Next.js which dynamic routes to pre-render at build time (e.g., all question pages). For paths not in generateStaticParams, behavior depends on dynamicParams config: (default dynamicParams = true): the page renders on-demand (SSR) for the first request, then is cached (ISR behavior). (dynamicParams = false): any path not pre-generated returns 404. AttemptIQ uses this for public question pages: popular questions are pre-generated at build, obscure ones render on-demand. The user never sees a 404 for a valid question ID. The first visitor to an un-generated path pays the SSR cost; subsequent visitors get the cached version.
POSTGRESQL INTERNALS
TC53: "What is a dead tuple in PostgreSQL and why does UPDATE create one instead of modifying in place?"
The trap: candidate says "it's a deleted row."
Wrong answer: "Dead tuples are rows deleted with DELETE."
Right answer: MVCC (Multi-Version Concurrency Control). When you UPDATE a row, PostgreSQL does NOT modify it in place. It marks the old row version as dead (sets xmax to the current transaction ID) and inserts a new row version with the updated values. The old version is a "dead tuple" — still physically on the heap page, visible to transactions that started before the update, invisible to transactions that started after. This is how PostgreSQL lets readers and writers coexist without blocking each other. AUTOVACUUM periodically scans the heap and marks dead tuple space as reusable — the space isn't returned to the OS (for that you need VACUUM FULL), just made available for new inserts. Heavy UPDATE workloads (like user_attempts is_correct corrections) create many dead tuples and require aggressive autovacuum settings.
TC54: "What does EXPLAIN ANALYZE show that EXPLAIN doesn't? When should you NOT use EXPLAIN ANALYZE?"
The trap: candidate says "EXPLAIN ANALYZE shows the query plan with actual rows."
Wrong answer: "EXPLAIN ANALYZE is always the right choice for debugging slow queries."
Right answer: EXPLAIN shows the planned query execution (estimates). EXPLAIN ANALYZE actually executes the query and shows actual rows, loops, and timing. The difference: EXPLAIN is free (no query execution). EXPLAIN ANALYZE runs the query — all its side effects happen. On a DELETE or UPDATE, EXPLAIN ANALYZE actually deletes/updates rows. On a live production database with a slow query that takes 30 seconds: EXPLAIN ANALYZE ties up that connection for 30 seconds while also potentially locking rows. Safe practice: use EXPLAIN ANALYZE wrapped in BEGIN; EXPLAIN ANALYZE ...; ROLLBACK; for mutating queries. For slow SELECT queries on production, EXPLAIN first to check the plan, then EXPLAIN ANALYZE on a replica or staging with production-like data.
TC55: "Your user_attempts table has a query: SELECT * FROM user_attempts WHERE session_id = $1 ORDER BY attempted_at DESC. Is there an index that would help this specific query?"
The trap: candidate says "index on session_id."
Wrong answer: "Just add CREATE INDEX ON user_attempts(session_id)."
Right answer: A single-column index on session_id would find the matching rows but then PostgreSQL must do a full heap fetch for all those rows, then sort by attempted_at. A composite index (session_id, attempted_at DESC) does both: finds the session rows AND they're already sorted in the index — no sort step needed. Even better if you frequently SELECT specific columns: CREATE INDEX ON user_attempts(session_id, attempted_at DESC) INCLUDE (question_id, selected_answer, is_correct) — an index-only scan returns the data without touching the heap at all. The choice depends on how frequently this query runs, how many rows per session (100 questions = small sort, probably fine without composite index), and write amplification cost (more index columns = more work on each INSERT).
TC56: "You run SELECT COUNT(*) FROM user_attempts. On a 10-million-row table, is this fast or slow? Why?"
The trap: candidate says "slow — it scans every row."
Wrong answer: "PostgreSQL stores the row count, so COUNT(*) is O(1)."
Right answer: Slow, and for a non-obvious reason. Unlike MySQL's MyISAM, PostgreSQL does NOT store a table's total row count. Every COUNT(*) does a sequential scan (or index scan of the smallest index). On 10M rows, this takes seconds. The reason: MVCC. Each transaction has its own snapshot — the "count" of visible rows is different for different transactions. PostgreSQL can't cache a single count that's valid for all concurrent transactions simultaneously. Workaround for approximate counts: SELECT reltuples FROM pg_class WHERE relname = 'user_attempts' — returns the last autovacuum's row estimate (stale but O(1)). For exact counts in analytics, use the materialized view (mv_user_subject_stats aggregates attempt counts per user per subject — querying the MV is fast).
TC57: "What is pg_stat_statements and why is it more useful than application-level logging for finding slow queries?"
The trap: candidate says "they're equivalent — both show query timings."
Wrong answer: "Application logging is better — you control what's logged."
Right answer: pg_stat_statements is a PostgreSQL extension that tracks statistics for every normalized query executed by the server: total execution count, total time, min/max/mean time, rows returned. Key advantages over app-level logging: (1) It normalizes queries — WHERE id = $1 regardless of the value. 10,000 different user IDs look like one query pattern, with an aggregate count and timing. App logging would show 10,000 separate log lines. (2) It includes queries from ALL clients — pgBouncer, migrations, other tools. App logging only captures queries your app explicitly logs. (3) It survives connection resets — statistics accumulate across connection pool cycling. To find your slowest queries: SELECT query, calls, mean_exec_time FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10. This shows total CPU time spent on each query pattern across all executions.
TC58: "Your connection pool has idleTimeoutMillis: 30000. A PM2 worker sits idle for 2 minutes. What happens to its database connections?"
The trap: candidate says "they stay open — the pool maintains them."
Wrong answer: "The pool keeps 10 connections open forever."
Right answer: idleTimeoutMillis: 30000 means: if a connection has been idle (not executing a query) for 30 seconds, the pool closes it and removes it from the pool. After 2 minutes of idle: all 10 connections have been closed. The next request that needs a DB connection triggers a new connection from scratch — new TCP handshake with pgBouncer, new pgBouncer→PostgreSQL connection setup. This adds ~10-50ms latency to the first request after a long idle period. For a low-traffic app (nights, weekends), every Monday morning "first request" is slower. This is usually acceptable — the alternative is keeping connections open indefinitely (resource waste on pgBouncer and PostgreSQL) or reducing idleTimeoutMillis (more frequent reconnect overhead on busy periods).
DEPLOYMENT AND PM2
TC59: "What is a graceful shutdown in PM2 and why does it matter during deployment?"
The trap: candidate says "it waits for requests to finish."
Wrong answer: "Graceful shutdown just waits a bit before killing the process."
Right answer: PM2's graceful shutdown sends SIGINT to the Node.js process, then waits up to kill_timeout (5 seconds in AttemptIQ's config) for the process to exit cleanly. The Next.js server catches SIGINT and stops accepting new connections, but finishes serving requests already in-flight. Without graceful shutdown: a SIGKILL immediately terminates the process — any in-flight request is interrupted mid-response, the client gets a connection reset error, any open database transactions are rolled back. For AttemptIQ during deployment (pm2 reload): the old worker finishes serving its current requests (up to 5 seconds), then exits. New requests are routed to the newly started workers. Zero requests are dropped during the deploy window.
TC60: "What is the difference between pm2 save and pm2 startup? If you run only pm2 start without pm2 save, what happens after a server reboot?"
The trap: candidate says "pm2 restarts automatically."
Wrong answer: "PM2 auto-restarts on reboot — no save needed."
Right answer: pm2 startup generates and installs a systemd unit file (on Ubuntu) that runs pm2 resurrect on system boot. pm2 save saves the current process list to ~/.pm2/dump.pm2. On boot: systemd runs PM2 → PM2 reads dump.pm2 → resurrects saved processes. Without pm2 save after starting your processes: the dump file doesn't include them → after reboot, PM2 starts but no processes are running. The AttemptIQ deploy sequence always ends with pm2 save for this reason. Without pm2 startup: PM2 itself doesn't start on boot (even if you ran pm2 save), so all processes are down after a reboot. Both must be configured once at server setup time.
TC61: "Your Nginx config has proxy_read_timeout 30s. A Next.js server-side render takes 35 seconds (heavy analytics page). What does the user see?"
The trap: candidate says "the page loads slowly after 35 seconds."
Wrong answer: "Nginx waits patiently — the user gets the page eventually."
Right answer: At 30 seconds, Nginx gives up waiting for Next.js to respond and sends the client a 504 Gateway Timeout. The user sees an error page. Next.js continues rendering (it doesn't know Nginx gave up) — it wastes 35 seconds of server CPU and the DB connection holds, but the result is discarded. Fix: either (a) increase proxy_read_timeout for the analytics route specifically, or (b) better: add Suspense boundaries so the page streams immediately and the 30-second timeout doesn't apply (streaming responses don't hit read timeout the same way — bytes are actively being sent). A 35-second SSR is always a bug, not a configuration problem. The real fix is the analytics materialized view approach AttemptIQ uses.
TC62: "What exactly does Nginx's limit_req_zone $binary_remote_addr zone=api:10m rate=120r/m do? What is the 10m for?"
The trap: candidate says "10m is the rate — 10 megabits."
Wrong answer: "10m means 10 megabits of traffic per second."
Right answer: 10m is the shared memory size for the rate limit zone — 10 megabytes. Nginx stores the rate limit state for each unique IP address. Each IPv4 address takes ~128 bytes in the zone. 10m = 10 megabytes = ~80,000 unique IP addresses tracked simultaneously. If more than 80,000 unique IPs hit the server, the oldest entries are evicted. rate=120r/m means 120 requests per minute (2 per second) per IP — this is the rate limit, not related to the memory size. $binary_remote_addr uses the binary representation of the IP (4 bytes for IPv4 vs 16 bytes for the string) — more memory-efficient than $remote_addr. For AttemptIQ's scale (thousands of users), 10m is far more than sufficient.
TC63: "You deploy a new build. A user was in the middle of a practice session (tab is open). They answer a question — the request hits the old PM2 worker during kill_timeout. What happens?"
The trap: candidate says "the request fails — the worker is shutting down."
Wrong answer: "The user loses their answer."
Right answer: The graceful shutdown ensures in-flight requests complete before the process exits. If the kill_timeout is 5 seconds and the attempt submission takes 20ms, the request completes normally before the 5-second timeout. The answer is saved. The next request (next question) hits a new worker — which is stateless, reads session state from PostgreSQL, and serves normally. The session state lives in PostgreSQL, not in PM2 worker memory. This is the stateless architecture benefit: any worker can handle any request for any user. The user's session is not tied to a specific PM2 worker — hot-swapping workers during deployment is seamless.
BULLMQ AND WORKER GOTCHAS
TC64: "Your BullMQ worker crashes mid-job (the server lost power). Is the job lost?"
The trap: candidate says "yes — the job was being processed."
Wrong answer: "The job is gone — you need to re-enqueue it manually."
Right answer: No. BullMQ uses a "stall detection" mechanism. When a worker picks up a job, it moves it from the waiting set to the active set in Redis and starts sending heartbeats (periodic ZADD commands) to the active sorted set. The job's heartbeat is a timestamp. If the worker crashes, heartbeats stop. When any worker starts up (or periodically), BullMQ checks the active set for jobs whose heartbeats are older than stalledInterval (default: 30 seconds). These stalled jobs are moved back to waiting and retried. The job is not lost — it replays. This means jobs must be idempotent: if a job runs partially and replays from the beginning, it must handle "already partially done" state correctly. For the MV refresh: REFRESH MATERIALIZED VIEW is idempotent — running it twice is fine.
TC65: "You have removeOnComplete: { count: 100 }. A job completes. When exactly is it removed — immediately, or later?"
The trap: candidate says "immediately after completion."
Wrong answer: "Completed jobs are deleted right away to save Redis memory."
Right answer: BullMQ keeps completed jobs in Redis up to the count limit. When a new job completes, BullMQ checks if the completed set exceeds count: 100. If it does, it removes the oldest completed jobs (those beyond the 100 limit). This means the most recent 100 completed jobs are always available for inspection. They are not removed immediately — they persist until the 101st job completes and bumps out the oldest. The practical implication: after an MV refresh, you can run queue.getCompleted() to see the last 100 completions and their data, timestamps, and results. This is the job history for debugging. removeOnFail: { count: 500 } works identically for failed jobs — keeps the 500 most recent failures.
TC66: "BullMQ has attempts: 3 configured. The job fails 3 times. What is its final state, and how do you inspect it?"
The trap: candidate says "it's deleted."
Wrong answer: "After 3 failures the job disappears."
Right answer: After exhausting all attempts, the job moves to the failed set in Redis (retained up to removeOnFail.count = 500 entries). The job record stores: the error message from the last failure, the stack trace, the attempt count, timestamps of each attempt, and the original job data. You can inspect failed jobs: const failed = await queue.getFailed() — returns an array of job objects. Each job has job.failedReason (error message) and job.stacktrace. You can manually retry: await job.retry() — moves it back to waiting. For production incidents where the analytics MV refresh fails 3 times (e.g., PostgreSQL OOM during refresh), you'd inspect cron_health, check Sentry for the error, fix the root cause, then manually retry the failed job or wait for the next scheduled run.
TYPESCRIPT AND TYPE SAFETY
TC67: "You write const user = data as UserProfile. TypeScript compiles with no errors. At runtime, user.plan_tier throws. How?"
The trap: candidate says "TypeScript should have caught this."
Wrong answer: "TypeScript guarantees type safety — this shouldn't happen."
Right answer: as casts are a promise to TypeScript: "I know more than you do about this type — trust me." TypeScript believes you and skips type checking on the cast. If data is actually { plan_tier: undefined } or null, TypeScript doesn't know — you told it the type is UserProfile. At runtime, user.plan_tier might be undefined even though TypeScript thinks it's 'free' | 'monthly' | 'annual'. The fix: use Zod to parse at runtime boundaries. const user = UserProfileSchema.parse(data) — if the shape doesn't match, it throws a descriptive validation error immediately, not a confusing TypeError: Cannot read property 'plan_tier' of undefined 10 lines later. AttemptIQ uses Zod on every API route input. The internal TypeScript types (from repositories) are trusted as-is — but external data (webhook payloads, user input) must be parsed, not cast.
TC68: "What is the difference between type and interface in TypeScript for the UserProfile type? Does it matter which you use?"
The trap: candidate says "they're identical — personal preference only."
Wrong answer: "No difference — use either."
Right answer: For most use cases, interchangeable. But three real differences: (1) Declaration merging: interface supports it — two interface UserProfile declarations in the same scope merge into one. type does not — second declaration is an error. (2) Extends: interface uses extends, type uses & intersection. interface UserProfile extends BaseUser vs type UserProfile = BaseUser & { ... }. Interfaces are more readable for OOP-style hierarchy. (3) Computed properties: type supports them (type Keys = { [K in 'a' | 'b']: string }), interface has limited support. In AttemptIQ: repository return types (Session, Attempt) use interface — they describe data shapes and could theoretically be extended. Service DTOs (CreatePracticeSessionDto) use interface for the same reason. The practical difference is negligible at AttemptIQ's scale.
TC69: "You have export const sessionsService = new SessionsService(...) at the bottom of services/sessions.service.ts. This runs at module import time. What is the gotcha?"
The trap: candidate says "nothing — singletons are fine."
Wrong answer: "This is the standard singleton pattern."
Right answer: The singleton is constructed when the module is first imported — which in Next.js App Router happens during the cold start of a PM2 worker. If the constructor reads process.env variables, those must be set at startup time. If DATABASE_URL is missing from .env, the Pool is constructed with undefined — not an error at construction time (pg.Pool is lazy about connections), but the first query fails. The deeper issue: Next.js's output: 'standalone' and module bundling might evaluate these singletons at build time in some configurations. More importantly: in a test environment, the singleton connects to the real database unless you mock the module. The service pattern uses constructor injection (new SessionsService(sessionsRepo, questionsRepo, attemptsRepo)) precisely to enable test-time mocking — but the singleton at the bottom bypasses this for production code that imports sessionsService directly.
TC70: "You see Type 'string | null' is not assignable to type 'string' in TypeScript. You fix it with ! (non-null assertion). Is this safe?"
The trap: candidate says "yes — if you know it's not null, assert it."
Wrong answer: "Non-null assertion is the correct fix when you're sure it's not null."
Right answer: ! is safe only when you can guarantee at that point in code that the value is not null/undefined — and that guarantee holds forever as the code evolves. Two problems: (1) If the guarantee breaks (a DB column that was NOT NULL becomes nullable in a migration), TypeScript doesn't warn you — the ! silences it. (2) It's a brittle guarantee — it relies on whoever reads the code knowing WHY the value can't be null at this point. Better alternatives: explicit null check (if (!value) throw new Error(...)), nullish coalescing (value ?? defaultValue), or Zod parsing (throws descriptive error if null). In AttemptIQ: session.question_ids ?? [] (nullish coalescing) is used throughout — if question_ids is null, fall back to empty array. Never session.question_ids! — that crashes if the DB returns null.
PAYMENTS AND MONEY
TC71: "Your payment amount is stored as amount_paise (integer, smallest currency unit). Why not store as a float like 999.99?"
The trap: candidate says "floats are fine — they're standard for money."
Wrong answer: "Use DECIMAL or FLOAT for currency."
Right answer: Floating-point numbers cannot represent all decimal values exactly in binary. 0.1 + 0.2 = 0.30000000000000004 in JavaScript (and most languages). For money, 999.99 stored as a float might be 999.9899999999... internally. Accumulated over thousands of transactions, rounding errors produce incorrect totals. The correct approach: store in the smallest currency unit as an integer (paise for INR, cents for USD). 999.99 INR = 99999 paise. Integer arithmetic is exact. When displaying: (amountPaise / 100).toFixed(2). AttemptIQ uses amount_paise (INTEGER in PostgreSQL) throughout — the payment webhook receives total_amount as a number (paise from Dodo), stores it as-is, displays by dividing by 100. PostgreSQL's NUMERIC type is also correct for money, but integer paise is simpler and faster.
TC72: "The Dodo payment webhook sends total_amount as an integer (paise). But what if the payment provider sends 100.0 (float) due to a bug in their SDK? What breaks?"
The trap: candidate says "nothing — 100.0 and 100 are the same number."
Wrong answer: "The value would be stored as 100 either way."
Right answer: In JavaScript, 100.0 === 100 is true — they're the same number. But the PostgreSQL column amount_paise is INTEGER. If you pass 100.0 (JavaScript number with decimal) to a parameterized query expecting INTEGER, pg driver passes it as a floating-point value. PostgreSQL casts it: 100.0::integer = 100 — fine. BUT: if the SDK sends 100.50 (half paise, which shouldn't exist but could be a bug), PostgreSQL truncates: 100.50::integer = 100. You store the wrong amount without any error. The safer approach: validate totalAmount is an integer: if (!Number.isInteger(totalAmount)) { return { status: 'skipped' } }. Currently AttemptIQ only checks <= 0 — fractional paise would silently truncate.
TC73: "What prevents a user from replaying a captured payment webhook to activate their plan a second time?"
The trap: candidate says "the webhook_events idempotency check."
Wrong answer: "The ON CONFLICT in webhook_events prevents replay."
Right answer: Two independent layers: (1) webhook_events unique constraint on dodo_payment_id — the same Dodo payment ID can only be inserted once. A replay of the same webhook event has the same dodo_payment_id → ON CONFLICT DO NOTHING → insertEvent returns 'already_processed' → handler returns early. (2) UPDATE payments WHERE id = $1 AND status = 'pending' RETURNING id — even if layer 1 somehow failed, the payment status is now 'captured' (set by the first processing). The UPDATE finds no rows matching status = 'pending' → paymentRows.length === 0 → ROLLBACK → returns 'already_processed'. The user's plan cannot be activated twice for the same payment even if both layers fail independently for different reasons. Defense in depth where money is concerned.
Closing Notes
The difference between a candidate who built something and a candidate who understands what they built is the ability to answer Layer 4 questions without hesitation — not because they memorized the answer, but because they can reason from first principles to the right answer in real time.
The mental model for every interview answer:
- State the direct answer immediately (never bury the lede)
- Reason to the metal — physics, OS, data structures
- Cite real code or real numbers
- Acknowledge the tradeoff honestly
- Anticipate and answer the follow-up
If you've internalized the content of this book and sd-book-deep.md, you can walk into any system design interview, get asked about AttemptIQ, and answer with the depth that earns a hire.
Every code snippet in this document is copied verbatim from the AttemptIQ codebase. This document does not modify any other file.