> Vault ← portfolio
← back
Interview Prep · ~10,283 words · 52 min read

Full-Stack Interview Preparation Guide (2025-2026)

Tailored for Purbayan Pramanik. TypeScript, React 19, Next.js 15, Rust, Go, C, systems programming, Zustand, Tailwind, Supabase/PostgreSQL.


How Modern Interviews Work (2025-2026)

The interview landscape has shifted. Companies no longer reward memorized trivia or whiteboard gymnastics performed in silence. What they want now: judgment under pressure.

The New Reality

AI tools like Copilot, Claude, and ChatGPT are expected in your workflow. Interviewers aren’t testing whether you can write a binary search from memory. They’re testing whether you can:

Typical Interview Pipeline

  1. Phone Screen (30-45 min) — Behavioral + light technical. “Walk me through your most complex project.” This is where your systems programming background shines.
  2. Coding Round (45-60 min) — Live coding, often with shared IDE. You can use AI tools at some companies, but you must explain every line.
  3. System Design (45-60 min) — Design a distributed system. Interviewers want to see you navigate ambiguity, ask clarifying questions, and make explicit tradeoff decisions.
  4. Behavioral (30-45 min) — STAR method. Real stories from real projects. Generic answers get filtered out instantly.
  5. Take-home (optional, 2-4 hours) — Some companies still do these. Ship clean, tested code. README matters.

What Interviewers Actually Look For

They’re not scoring you on a rubric of correct answers. They’re evaluating:

Purbayan’s Angle: Your mechanical engineering background is a feature, not a bug. Engineers think in systems, constraints, and tradeoffs. That’s exactly what interviewers want. Lead with “I approach software the way I approach engineering problems: define constraints, prototype, measure, iterate.”


JavaScript & TypeScript Deep Theory

Event Loop

The event loop is JavaScript’s concurrency model. Single-threaded, but non-blocking. Understanding it separates people who write JS from people who understand JS.

How it works:

The call stack executes synchronous code. When an async operation completes, its callback goes into a queue. There are two queues that matter:

The order: current call stack empties → all microtasks drain → one macrotask runs → repeat.

console.log("A") // sync — runs first
setTimeout(() => console.log("B"), 0) // macrotask queue
Promise.resolve().then(() => console.log("C")) // microtask queue
console.log("D") // sync — runs second

// Output: A, D, C, B

Why A, D, C, B? Synchronous code (A, D) runs first because it’s on the call stack. Then the microtask queue drains (C from the resolved Promise). Then the macrotask queue runs (B from setTimeout, even though the delay is 0).

🎯 Interview Tip: Always trace through async code execution step by step. Interviewers want to see you reason about timing, not memorize output.

Interview Question: “What’s the output of this code and why?”

async function foo() {
  console.log("1")
  await Promise.resolve()
  console.log("2")
}

console.log("3")
foo()
console.log("4")

// Output: 3, 1, 4, 2

console.log('3') runs first. Then foo() is called. Inside foo, console.log('1') runs synchronously. The await pauses foo and schedules the rest as a microtask. Control returns to the caller, so console.log('4') runs. Then the microtask queue drains: console.log('2').

Purbayan’s Angle: Connect this to your TCP server work in 4at. “I built a TCP chat server in Rust where I had to think about event-driven I/O at the OS level. JavaScript’s event loop is a higher-level abstraction of the same concept: non-blocking I/O with an event queue.”


Closures

A closure is a function that remembers the variables from its lexical scope, even after that scope has finished executing.

function createCounter() {
  let count = 0
  return {
    increment: () => ++count,
    getCount: () => count,
  }
}

const counter = createCounter()
counter.increment()
counter.increment()
console.log(counter.getCount()) // 2
// `count` is not accessible directly — it's enclosed

Why closures matter in practice:

  1. Encapsulation — private variables without classes
  2. Function factoriescreateLogger('DEBUG') returns a function pre-configured with a log level
  3. Callbacks and event handlers — every addEventListener callback that references outer variables is a closure

The stale closure trap in React:

function Timer() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const id = setInterval(() => {
      // BUG: `count` is captured at 0, never updates
      console.log(count)
      setCount(count + 1) // always sets to 1
    }, 1000)
    return () => clearInterval(id)
  }, []) // empty deps = closure captures initial count
}

Fix: use the functional updater setCount(prev => prev + 1) or add count to the dependency array (but then the interval resets every second).

⚠️ Watch Out: Stale closures are the #1 React debugging nightmare. Always check dependency arrays in useEffect and useCallback.

Interview Question: “How do closures cause memory leaks?”

If a closure holds a reference to a large object and the closure itself is long-lived (stored in a global, an event listener that’s never removed, a timer that’s never cleared), the garbage collector can’t reclaim that object. The fix: clean up event listeners, clear timers, and use WeakRef when appropriate.

Purbayan’s Angle: “I ran into stale closures when working with tldraw at fiddle. The canvas editor instance was captured in a useEffect closure, and when the component re-rendered with a new editor reference, the old closure still pointed to the stale one. Understanding closure semantics was critical for debugging that.”


Prototypal Inheritance

JavaScript doesn’t have classical inheritance. It has prototypal inheritance: objects delegate to other objects through the prototype chain.

const animal = {
  speak() {
    return `${this.name} makes a sound`
  },
}

const dog = Object.create(animal)
dog.name = "Rex"
dog.bark = function () {
  return `${this.name} barks`
}

dog.speak() // "Rex makes a sound" — delegated to animal
dog.bark() // "Rex barks" — own method

The prototype chain: When you access a property on an object, JS looks at the object itself first. If not found, it follows __proto__ to the prototype, then the prototype’s prototype, all the way up to Object.prototype, then null.

ES6 classes are syntactic sugar:

class Animal {
  constructor(name) {
    this.name = name
  }
  speak() {
    return `${this.name} makes a sound`
  }
}

// Under the hood, this creates:
// - A constructor function `Animal`
// - `Animal.prototype.speak = function() { ... }`
// - `new Animal('Rex')` creates an object with __proto__ = Animal.prototype

Interview Question: “What’s the difference between Object.create() and the new keyword?”

Object.create(proto) creates a new object with proto as its prototype. No constructor is called. new Constructor() creates a new object, sets its prototype to Constructor.prototype, calls the constructor with this bound to the new object, and returns it (unless the constructor explicitly returns an object).


Temporal Dead Zone (TDZ)

let and const are hoisted, but they’re not initialized until the declaration is reached. The gap between the start of the scope and the declaration is the Temporal Dead Zone.

console.log(x) // ReferenceError: Cannot access 'x' before initialization
let x = 5

console.log(y) // undefined (var is hoisted AND initialized to undefined)
var y = 5

Why TDZ exists: Bug prevention. With var, you could accidentally use a variable before it was assigned and get undefined silently. TDZ makes this a loud error.

Interview Question: “When can AI refactoring tools break code with TDZ issues?”

When an AI tool refactors var to let/const (a common “modernization” suggestion), it can introduce TDZ errors if the original code relied on var’s hoisting behavior. Example:

// Original — works because var hoists
function init() {
  setup(config)
  var config = getConfig()
}

// AI "fix" — breaks because of TDZ
function init() {
  setup(config) // ReferenceError!
  const config = getConfig()
}

Always review AI refactoring suggestions. Mechanical transformations miss semantic context.


The this Keyword

this in JavaScript is determined by how a function is called, not where it’s defined. Four binding rules, in order of precedence:

  1. new bindingnew Foo() creates a new object, this = that object
  2. Explicit bindingcall(), apply(), bind() set this explicitly
  3. Implicit bindingobj.method() sets this = obj
  4. Default binding — standalone function call, this = undefined (strict mode) or globalThis

Arrow functions are different. They don’t have their own this. They inherit this from the enclosing lexical scope.

const obj = {
  name: "Purbayan",
  greet: function () {
    return this.name
  }, // implicit binding: this = obj
  greetArrow: () => this.name, // lexical: this = outer scope (module/global)
  greetDelayed: function () {
    setTimeout(() => {
      console.log(this.name) // arrow inherits this from greetDelayed
    }, 100)
  },
}

obj.greet() // "Purbayan"
obj.greetArrow() // undefined (or global name)
obj.greetDelayed() // "Purbayan" — arrow captures the right `this`

Interview Question: “Why do class methods lose this when passed as callbacks?”

class Button {
  label = "Click me"
  handleClick() {
    console.log(this.label)
  }
}

const btn = new Button()
document.addEventListener("click", btn.handleClick) // `this` is the DOM element, not btn

Fixes: arrow function in class field (handleClick = () => { ... }), or bind in the constructor, or wrap in an arrow function at the call site.


Promises & async/await

A Promise represents a value that may not exist yet. Three states: pending, fulfilled, rejected. Once settled, it can’t change.

Error handling patterns:

// Promise chain — .catch() handles any error in the chain
fetchUser(id)
  .then((user) => fetchPosts(user.id))
  .then((posts) => render(posts))
  .catch((err) => showError(err)) // catches errors from any step

// async/await — try/catch
async function loadUserPosts(id) {
  try {
    const user = await fetchUser(id)
    const posts = await fetchPosts(user.id)
    return posts
  } catch (err) {
    showError(err)
  }
}

Promise combinators:

MethodBehaviorUse Case
Promise.allRejects if ANY rejectsFetch multiple resources, all required
Promise.allSettledNever rejects, returns status of eachFetch multiple, handle partial failures
Promise.raceSettles with first to settleTimeout pattern
Promise.anyResolves with first to resolveFastest mirror/CDN

Interview Question: “Implement a timeout wrapper for any promise.”

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), ms))
  return Promise.race([promise, timeout])
}

// Usage
const data = await withTimeout(fetch("/api/data"), 5000)

Purbayan’s Angle: “In my fiddle work, I dealt with race conditions between React Query cache invalidation and tldraw’s async initialization. Understanding Promise semantics was essential for sequencing the canvas load correctly.”


TypeScript Specifics

Generics constrain types without losing type information:

// Without generics — loses type info
function first(arr: any[]): any {
  return arr[0]
}

// With generics — preserves type
function first<T>(arr: T[]): T {
  return arr[0]
}
const n = first([1, 2, 3]) // type: number
const s = first(["a", "b"]) // type: string

Discriminated Unions are the TypeScript pattern for handling variants:

type Result<T> = { status: "success"; data: T } | { status: "error"; error: Error }

function handle<T>(result: Result<T>) {
  if (result.status === "success") {
    // TypeScript knows result.data exists here
    console.log(result.data)
  } else {
    // TypeScript knows result.error exists here
    console.error(result.error.message)
  }
}

Conditional Types:

type IsString<T> = T extends string ? true : false
type A = IsString<"hello"> // true
type B = IsString<42> // false

// Practical: extract return type of async functions
type UnwrapPromise<T> = T extends Promise<infer U> ? U : T
type Data = UnwrapPromise<Promise<string>> // string

Mapped Types and Utility Types:

// Make all properties optional
type Partial<T> = { [K in keyof T]?: T[K] }

// Pick specific properties
type Pick<T, K extends keyof T> = { [P in K]: T[P] }

// Real-world: API response where some fields are optional on update
type UserUpdate = Partial<Pick<User, "name" | "email" | "avatar">>

Interview Question: “Write a type-safe event emitter.”

type EventMap = {
  click: { x: number; y: number }
  keypress: { key: string }
}

class TypedEmitter<T extends Record<string, unknown>> {
  private listeners = new Map<keyof T, Set<(data: any) => void>>()

  on<K extends keyof T>(event: K, handler: (data: T[K]) => void) {
    if (!this.listeners.has(event)) this.listeners.set(event, new Set())
    this.listeners.get(event)!.add(handler)
  }

  emit<K extends keyof T>(event: K, data: T[K]) {
    this.listeners.get(event)?.forEach((fn) => fn(data))
  }
}

const emitter = new TypedEmitter<EventMap>()
emitter.on("click", ({ x, y }) => {
  /* x and y are typed as number */
})
emitter.emit("keypress", { key: "Enter" }) // type-checked

Purbayan’s Angle: “I use TypeScript extensively with Zustand stores and Next.js server actions. Discriminated unions are my go-to for modeling API response states, and I use generics heavily in reusable form components.”


Modules (ESM vs CommonJS)

CommonJS (require/module.exports) — synchronous, Node.js default (historically). Loads modules at runtime.

ESM (import/export) — asynchronous, static analysis possible. The standard going forward.

Key difference: ESM imports are live bindings (they reflect the current value of the export), while CommonJS copies the value at require time.

// ESM — live binding
// counter.mjs
export let count = 0
export function increment() {
  count++
}

// main.mjs
import { count, increment } from "./counter.mjs"
console.log(count) // 0
increment()
console.log(count) // 1 — live binding reflects the change

// CommonJS — copied value
// counter.cjs
let count = 0
module.exports = {
  count,
  increment: () => {
    count++
  },
}

// main.cjs
const { count, increment } = require("./counter.cjs")
console.log(count) // 0
increment()
console.log(count) // 0 — still 0, it's a copy

Tree shaking works with ESM because imports are statically analyzable. Bundlers can determine at build time which exports are unused and remove them. CommonJS require() can be dynamic (require(someVariable)), making static analysis impossible.

Circular dependencies: ESM handles them better because of live bindings. CommonJS can return partially initialized modules.


WeakMap / WeakSet

Keys in a WeakMap must be objects, and they’re held weakly. If nothing else references the key object, it gets garbage collected along with its associated value.

// Private data pattern
const privateData = new WeakMap()

class User {
  constructor(name, ssn) {
    this.name = name
    privateData.set(this, { ssn }) // truly private
  }
  getSSN() {
    return privateData.get(this).ssn
  }
}

const user = new User("Purbayan", "123-45-6789")
user.getSSN() // works
// When `user` is garbage collected, the WeakMap entry is too

Use cases: DOM node metadata, caching computed results for objects without preventing GC, associating data with third-party objects you don’t control.


Proxy / Reflect

Proxy wraps an object and intercepts operations (get, set, delete, function calls, etc.). Reflect provides the default behavior for each trap.

const handler = {
  get(target, prop, receiver) {
    console.log(`Accessing ${String(prop)}`)
    return Reflect.get(target, prop, receiver)
  },
  set(target, prop, value, receiver) {
    if (typeof value !== "string") throw new TypeError("Only strings allowed")
    return Reflect.set(target, prop, value, receiver)
  },
}

const obj = new Proxy({}, handler)
obj.name = "Purbayan" // logs: Accessing name (on next get)
obj.age = 25 // throws TypeError

Interview Question: “How do reactive frameworks like Vue use Proxy?”

Vue 3’s reactivity system wraps data objects in Proxies. The get trap tracks which components depend on which properties (dependency tracking). The set trap triggers re-renders for dependent components when a property changes. This is more performant and complete than Vue 2’s Object.defineProperty approach, which couldn’t detect property additions or array index mutations.


React 19 + Next.js 15 (Frontend Deep Dive)

React Server Components (RSC)

Server Components run on the server and send rendered HTML + a serialized component tree to the client. They never ship JavaScript to the browser.

RSC vs SSR:

// Server Component (default in Next.js App Router)
// This code NEVER ships to the browser
async function UserProfile({ userId }: { userId: string }) {
  const user = await db.query("SELECT * FROM users WHERE id = $1", [userId])
  return <div>{user.name}</div>
}

// Client Component — needs 'use client' directive
;("use client")
import { useState } from "react"

function LikeButton() {
  const [liked, setLiked] = useState(false)
  return <button onClick={() => setLiked(!liked)}>{liked ? "Liked" : "Like"}</button>
}

When to use which:

💡 Key Insight: Server Components are NOT SSR. SSR ships JS for hydration. Server Components ship zero JS. This distinction matters in interviews.

The ‘use server’ directive marks functions as Server Actions, callable from client components:

// actions.ts
"use server"

export async function createPost(formData: FormData) {
  const title = formData.get("title") as string
  await db.insert("posts", { title })
  revalidatePath("/posts")
}

Purbayan’s Angle: “At fiddle, I worked with a component preview system where server components were perfect for rendering component metadata and documentation, while the actual interactive preview needed client components. Understanding the RSC boundary was critical for keeping the bundle size small.”


React 19 New Features

useActionState (replaces useFormState):

"use client"
import { useActionState } from "react"
import { submitForm } from "./actions"

function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitForm, null)

  return (
    <form action={formAction}>
      <input name="email" type="email" />
      <button disabled={isPending}>{isPending ? "Sending..." : "Submit"}</button>
      {state?.error && <p className="text-red-500">{state.error}</p>}
    </form>
  )
}

useOptimistic for instant UI feedback:

"use client"
import { useOptimistic } from "react"

function Messages({ messages }: { messages: Message[] }) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (state, newMessage: string) => [
      ...state,
      { id: crypto.randomUUID(), text: newMessage, sending: true },
    ],
  )

  async function sendMessage(formData: FormData) {
    const text = formData.get("text") as string
    addOptimistic(text) // instant UI update
    await submitMessage(text) // actual server call
  }

  return (
    <div>
      {optimisticMessages.map((msg) => (
        <p key={msg.id} style={{ opacity: msg.sending ? 0.5 : 1 }}>
          {msg.text}
        </p>
      ))}
      <form action={sendMessage}>
        <input name="text" />
        <button>Send</button>
      </form>
    </div>
  )
}

React Compiler (React Forget): Automatically memoizes components and values. Makes manual useMemo, useCallback, and React.memo largely unnecessary. The compiler analyzes your code at build time and inserts memoization where beneficial.

useFormStatus reads the status of a parent form:

"use client"
import { useFormStatus } from "react-dom"

function SubmitButton() {
  const { pending } = useFormStatus()
  return <button disabled={pending}>{pending ? "Saving..." : "Save"}</button>
}

Virtual DOM & Reconciliation

React maintains a virtual representation of the UI in memory. When state changes, React creates a new virtual tree, diffs it against the previous one, and applies the minimal set of DOM mutations.

Fiber Architecture (React 16+): The reconciler was rewritten to be incremental. Instead of recursively diffing the entire tree synchronously (blocking the main thread), Fiber breaks work into units that can be paused, resumed, and prioritized.

Concurrent Rendering (React 18+): Fiber enables concurrent features. React can prepare multiple versions of the UI simultaneously, interrupt low-priority renders for high-priority updates (like user input), and show intermediate states with Suspense.

Key diffing rules:

  1. Elements of different types produce different trees (full remount)
  2. Elements of the same type are updated in place (attributes diffed)
  3. key prop helps React identify which items in a list changed, moved, or were removed

Interview Question: “Why shouldn’t you use array index as a key?”

If items are reordered, inserted, or deleted, index-based keys cause React to update the wrong elements. A todo list where you delete the first item: with index keys, React thinks item 0 changed content, item 1 changed content, and the last item was removed. With stable IDs as keys, React correctly identifies that item 0 was removed.


State Management

When to use what:

ToolUse Case
useStateLocal component state, simple values
useReducerComplex state logic, multiple related values, state machines
Context APIInfrequently changing global state (theme, locale, auth)
ZustandFrequently changing shared state, when Context causes too many re-renders
Server state (React Query / SWR)Remote data, caching, synchronization

Why Zustand over Context for frequent updates:

Context triggers re-renders for every consumer when the value changes, even if a consumer only uses a slice of the state. Zustand uses external stores with selectors, so components only re-render when their selected slice changes.

// Zustand store
import { create } from 'zustand';

interface EditorStore {
  selectedTool: string;
  zoom: number;
  setTool: (tool: string) => void;
  setZoom: (zoom: number) => void;
}

const useEditorStore = create<EditorStore>((set) => ({
  selectedTool: 'select',
  zoom: 1,
  setTool: (tool) => set({ selectedTool: tool }),
  setZoom: (zoom) => set({ zoom }),
}));

// Component only re-renders when `zoom` changes
function ZoomIndicator() {
  const zoom = useEditorStore((state) => state.zoom);
  return <span>{Math.round(zoom * 100)}%</span>;
}

Purbayan’s Angle: “I use Zustand for editor state in canvas-heavy applications. At fiddle, the component preview system had complex state (selected component, viewport size, theme, zoom level) that needed to be shared across the toolbar, canvas, and sidebar without causing cascade re-renders. Zustand’s selector pattern was perfect.”


useMemo / useCallback / React.memo

useMemo caches a computed value between renders:

const sortedItems = useMemo(() => items.sort((a, b) => a.name.localeCompare(b.name)), [items])

useCallback caches a function reference:

const handleClick = useCallback((id: string) => {
  setSelected(id)
}, [])

React.memo prevents re-renders if props haven’t changed (shallow comparison).

When they hurt: Every memoization has a cost (memory for cached values, comparison overhead). If the computation is cheap or the component is simple, memoization adds complexity without benefit.

React Compiler changes everything: With React 19’s compiler, manual memoization becomes largely unnecessary. The compiler automatically determines what needs memoization. Write straightforward code and let the compiler optimize.

Interview Question: “When would you still manually memoize even with React Compiler?”

Edge cases: expensive computations that the compiler can’t statically analyze (dynamic dependencies), third-party library integration where you need referential stability for external subscriptions, and performance-critical paths where you want explicit control.


Rendering Strategies

StrategyWhen HTML is GeneratedJS ShippedUse Case
CSRIn browserFull bundleSPAs, dashboards behind auth
SSRPer request on serverFull bundle for hydrationDynamic, personalized pages
SSGAt build timeMinimalBlog posts, docs, marketing
ISRAt build time + revalidatedMinimalE-commerce products, frequently updated content

Next.js 15 App Router defaults to Server Components (SSR/SSG depending on data). Pages are static by default unless they use dynamic functions (cookies(), headers(), searchParams).

Partial Prerendering (PPR): Next.js 15’s experimental feature. A page can be partially static and partially dynamic. The static shell loads instantly, and dynamic parts stream in with Suspense boundaries.

export default function ProductPage({ params }: { params: { id: string } }) {
  return (
    <div>
      {/* Static shell — rendered at build time */}
      <Header />
      <ProductInfo id={params.id} />

      {/* Dynamic — streams in */}
      <Suspense fallback={<Skeleton />}>
        <Reviews productId={params.id} />
      </Suspense>

      <Suspense fallback={<Skeleton />}>
        <Recommendations productId={params.id} />
      </Suspense>
    </div>
  )
}

Hydration

Hydration is the process where React attaches event listeners and state to server-rendered HTML. The server sends static HTML (fast initial paint), then React “hydrates” it by making it interactive.

Hydration mismatch errors happen when the server-rendered HTML doesn’t match what React expects on the client. Common causes:

Streaming SSR sends HTML in chunks as components resolve. Combined with Suspense, the server can send the shell immediately and stream in data-dependent sections as they become ready.

Selective Hydration (React 18+): React can hydrate different parts of the page independently. If a user clicks on a section that hasn’t hydrated yet, React prioritizes hydrating that section.


Next.js Routing (App Router)

Parallel Routes render multiple pages in the same layout simultaneously:

app/
  @dashboard/
    page.tsx
  @analytics/
    page.tsx
  layout.tsx    ← receives both as props: { dashboard, analytics }

Intercepting Routes show a route in a modal while keeping the background page:

app/
  feed/
    page.tsx
    (..)photo/[id]/   ← intercepts /photo/[id] when navigating from feed
      page.tsx
  photo/[id]/
    page.tsx          ← direct URL access shows full page

Route Handlers replace API routes:

// app/api/users/route.ts
export async function GET(request: Request) {
  const users = await db.query("SELECT * FROM users")
  return Response.json(users)
}

export async function POST(request: Request) {
  const body = await request.json()
  const user = await db.insert("users", body)
  return Response.json(user, { status: 201 })
}

Performance

Core Web Vitals:

⚠️ Watch Out: Never lazy-load above-the-fold content. It hurts LCP. Only lazy-load what’s below the viewport.

Optimization techniques:

  1. Code splittingdynamic() in Next.js, React.lazy() for client components
  2. Image optimizationnext/image with automatic sizing, format conversion, lazy loading
  3. Font optimizationnext/font for zero-layout-shift font loading
  4. Bundle analysis@next/bundle-analyzer to find bloated dependencies
  5. React Profiler — identify unnecessary re-renders in dev tools

Interview Question: “A page has poor LCP. How do you diagnose and fix it?”

  1. Open Chrome DevTools Performance panel, run a Lighthouse audit
  2. Identify the LCP element (usually a hero image or heading)
  3. Check: Is the image lazy-loaded? (It shouldn’t be if it’s above the fold.) Is the font blocking render? Are there render-blocking scripts?
  4. Fix: preload the LCP image, use next/font, defer non-critical JS, check server response time
  5. Measure again. Compare before/after.

Forms and Mutations (Server Actions)

Server Actions are async functions that run on the server, callable directly from client components. They replace the need for API routes for mutations.

// app/actions.ts
"use server"

import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"

export async function createProject(formData: FormData) {
  const name = formData.get("name") as string

  // Validate
  if (!name || name.length < 3) {
    return { error: "Name must be at least 3 characters" }
  }

  // Mutate
  await db.insert("projects", { name, createdAt: new Date() })

  // Revalidate cached data
  revalidatePath("/projects")

  // Redirect
  redirect("/projects")
}

Optimistic updates pattern:

"use client"

import { useOptimistic, useTransition } from "react"
import { toggleLike } from "./actions"

function LikeButton({ postId, initialLiked }: { postId: string; initialLiked: boolean }) {
  const [optimisticLiked, setOptimisticLiked] = useOptimistic(initialLiked)
  const [isPending, startTransition] = useTransition()

  return (
    <button
      onClick={() => {
        startTransition(async () => {
          setOptimisticLiked(!optimisticLiked)
          await toggleLike(postId)
        })
      }}
    >
      {optimisticLiked ? "Unlike" : "Like"}
    </button>
  )
}

Purbayan’s Angle: “At fiddle, I implemented server actions for component CRUD operations. The pattern of optimistic updates with revalidation was essential for making the UI feel responsive while keeping the server as the source of truth.”


Systems Programming (Purbayan’s Unique Edge)

This section is your differentiator. Most frontend candidates can’t talk about TCP internals, FFT implementations, or memory management. You can.

TCP/IP Networking

How TCP works:

  1. Three-way handshake: Client sends SYN, server responds SYN-ACK, client sends ACK. Connection established.
  2. Data transfer: Segments are sent with sequence numbers. Receiver acknowledges with ACK. Lost segments are retransmitted.
  3. Flow control: Receiver advertises a window size (how much data it can buffer). Sender doesn’t exceed this.
  4. Congestion control: Slow start, congestion avoidance, fast retransmit. The sender probes network capacity and backs off when it detects congestion.

Interview Question: “You built a TCP chat server (4at). Walk me through the architecture.”

“4at is a TCP chat server written in Rust. Each client connection spawns an async task using Tokio. Messages are broadcast to all connected clients through a shared channel (tokio::sync::broadcast). I implemented rate limiting per client to prevent spam. The key challenge was managing shared state (the list of connected clients) safely across concurrent tasks. Rust’s ownership system, combined with Arc<Mutex<>>, made data races a compile-time error rather than a runtime bug.”

Purbayan’s Angle: Lead with this in system design rounds. “I didn’t just use WebSockets through a library. I built a TCP server from scratch, handling connection lifecycle, message framing, and concurrent client management. That gives me a deep understanding of what’s happening under the abstraction.”


Concurrency Models

OS Threads:

Green Threads / Async Tasks (Rust’s Tokio, Go’s goroutines):

Rust’s approach: async/await with Tokio. No garbage collector. Ownership + Arc<Mutex<T>> for shared state. The compiler prevents data races at compile time.

use tokio::sync::Mutex;
use std::sync::Arc;

let shared_state = Arc::new(Mutex::new(Vec::new()));

let state = shared_state.clone();
tokio::spawn(async move {
    let mut data = state.lock().await;
    data.push("hello");
});

Go’s approach: Goroutines + channels. “Don’t communicate by sharing memory; share memory by communicating.”

ch := make(chan string)

go func() {
    ch <- "hello" // send
}()

msg := <-ch // receive

Interview Question: “When would you choose Rust over Go for a concurrent system?”

Rust when you need: zero-cost abstractions, no GC pauses, compile-time safety guarantees, maximum performance (game servers, embedded systems, real-time audio). Go when you need: fast development, simpler concurrency model, large standard library, quick prototyping of network services.


FFT & DSP Basics

What FFT does: Transforms a signal from the time domain to the frequency domain. Given audio samples over time, FFT tells you which frequencies are present and their amplitudes.

The math (simplified): The Discrete Fourier Transform (DFT) computes N frequency bins from N time samples. Naive DFT is O(N^2). The Fast Fourier Transform (Cooley-Tukey algorithm) exploits symmetry in the computation using “butterfly operations” to achieve O(N log N).

Butterfly operation: At each stage, pairs of values are combined using complex multiplication and addition/subtraction. The “twiddle factors” (complex roots of unity) rotate the values in the complex plane.

Interview Question: “Tell me about your musializer project.”

“Musializer is a music visualizer I built from scratch. I implemented the FFT algorithm in C, not using a library. The program reads audio samples, applies a windowing function (Hann window to reduce spectral leakage), runs FFT to get frequency magnitudes, and renders them as a real-time visualization. The key insight was understanding that FFT output bins map to frequency ranges: bin k corresponds to frequency k * sampleRate / N. I used logarithmic scaling for the display because human hearing is logarithmic.”

Purbayan’s Angle: This project demonstrates you can go from mathematical theory to working implementation. Very few candidates can say “I implemented FFT from the math.” Use it to show depth.


Search Algorithms (TF-IDF & Inverted Index)

TF-IDF (Term Frequency - Inverse Document Frequency):

Inverted Index: Maps each term to the list of documents containing it. Instead of scanning every document for a query term, you look up the term and instantly get all matching documents.

"rust"    → [doc3, doc7, doc15]
"async"   → [doc3, doc7, doc22, doc41]
"tokio"   → [doc7, doc15]

Query "rust async" → intersection of [doc3, doc7, doc15] and [doc3, doc7, doc22, doc41] → [doc3, doc7]

Tokenization: Breaking text into searchable terms. Involves lowercasing, removing punctuation, stemming (“running” -> “run”), and removing stop words (“the”, “is”, “at”).

Interview Question: “Walk me through your seroost search engine.”

“Seroost is a local search engine I built from scratch. It indexes files on disk by tokenizing their content, building an inverted index, and computing TF-IDF scores. When you search, it looks up each query term in the inverted index, computes relevance scores, and returns ranked results. I built it to understand how search actually works under the hood, not just how to call Elasticsearch.”


Memory Management

Stack vs Heap:

StackHeap
AllocationAutomatic, LIFOManual or GC-managed
SpeedVery fast (pointer bump)Slower (fragmentation, allocation strategy)
SizeFixed per thread (1-8 MB)Limited by system memory
LifetimeTied to function scopeArbitrary
DataLocal variables, function argsDynamic data, objects, strings

Rust’s ownership model:

fn main() {
    let s1 = String::from("hello"); // s1 owns the String
    let s2 = s1;                     // ownership MOVES to s2, s1 is invalid
    // println!("{}", s1);           // compile error: s1 was moved

    let s3 = s2.clone();            // explicit deep copy
    println!("{} {}", s2, s3);      // both valid
}

Borrowing: References that don’t take ownership. Rules: you can have either one mutable reference OR any number of immutable references, but not both simultaneously.

fn calculate_length(s: &String) -> usize {  // borrows, doesn't own
    s.len()
}  // s goes out of scope, but since it doesn't own the String, nothing is dropped

Lifetimes: Tell the compiler how long references are valid. Prevent dangling references at compile time.

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
// The returned reference lives at least as long as the shorter of x and y

C’s manual management: malloc/free. Full control, full responsibility. Use-after-free, double-free, and memory leaks are your problem.

Go’s garbage collection: Concurrent, tri-color mark-and-sweep. Low-latency pauses (sub-millisecond in Go 1.22+). You don’t manage memory, but you pay for GC pauses.

Purbayan’s Angle: “I’ve worked across the memory management spectrum. C for musializer (manual malloc/free), Rust for 4at (ownership/borrowing), Go for DSA practice (GC). Each model has tradeoffs. Rust’s ownership is the sweet spot for systems programming: zero-cost safety.”


Concurrency Safety

Race condition: Two threads access shared data, at least one writes, and there’s no synchronization. The result depends on timing.

Deadlock: Thread A holds lock 1 and waits for lock 2. Thread B holds lock 2 and waits for lock 1. Neither can proceed.

Rust’s compile-time prevention:

use std::sync::{Arc, Mutex, RwLock};

// Mutex — exclusive access
let data = Arc::new(Mutex::new(vec![1, 2, 3]));
{
    let mut guard = data.lock().unwrap();
    guard.push(4); // only one thread can access at a time
} // lock released when guard is dropped

// RwLock — multiple readers OR one writer
let data = Arc::new(RwLock::new(vec![1, 2, 3]));
{
    let read_guard = data.read().unwrap(); // multiple readers OK
    println!("{:?}", *read_guard);
}
{
    let mut write_guard = data.write().unwrap(); // exclusive write
    write_guard.push(4);
}

Rust’s type system ensures you can’t accidentally share mutable data across threads. Send and Sync traits are automatically derived (or not) based on the type’s contents. If a type isn’t Send, you literally can’t send it to another thread.


Databases & Backend

SQL vs NoSQL

SQL (PostgreSQL, MySQL):

NoSQL (MongoDB, Redis, DynamoDB):

Interview Question: “When would you choose NoSQL over SQL?”

When your data is naturally document-shaped (CMS content, user profiles with varying fields), when you need massive horizontal scale with simple access patterns, or when schema flexibility is more important than relational integrity. But don’t reach for NoSQL just because “it scales.” PostgreSQL with proper indexing handles millions of rows without breaking a sweat.

Purbayan’s Angle: “I use PostgreSQL through Supabase for most projects because relational data with Row Level Security covers 90% of use cases. I’ve also used MongoDB when the data was genuinely document-shaped. The choice should be driven by data access patterns, not hype.”


ACID Properties

Transaction Isolation Levels:

LevelDirty ReadNon-repeatable ReadPhantom Read
Read UncommittedPossiblePossiblePossible
Read CommittedNoPossiblePossible
Repeatable ReadNoNoPossible
SerializableNoNoNo

PostgreSQL defaults to Read Committed. Serializable is safest but slowest.


Indexing

B-tree index (default in PostgreSQL): Balanced tree structure. O(log N) lookups. Good for equality and range queries. Supports ordering.

Hash index: O(1) lookups for equality only. No range queries, no ordering.

Composite index: Index on multiple columns. Column order matters. An index on (last_name, first_name) helps queries filtering by last_name alone or last_name + first_name, but NOT first_name alone.

When indexes hurt:

EXPLAIN ANALYZE:

EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'purbayan@example.com';

-- Look for:
-- Seq Scan (bad for large tables — missing index)
-- Index Scan (good — using the index)
-- Actual time vs estimated time
-- Rows removed by filter (high number = bad selectivity)

PostgreSQL + Supabase Specifics

Row Level Security (RLS):

-- Enable RLS on a table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;

-- Users can only see their own posts
CREATE POLICY "Users see own posts" ON posts
  FOR SELECT USING (auth.uid() = user_id);

-- Users can only insert posts as themselves
CREATE POLICY "Users insert own posts" ON posts
  FOR INSERT WITH CHECK (auth.uid() = user_id);

Real-time subscriptions:

const channel = supabase
  .channel("posts")
  .on(
    "postgres_changes",
    {
      event: "INSERT",
      schema: "public",
      table: "posts",
    },
    (payload) => {
      console.log("New post:", payload.new)
    },
  )
  .subscribe()

Edge Functions: Deno-based serverless functions deployed to Supabase’s edge network. Good for webhooks, custom auth logic, and third-party API integration.


REST vs GraphQL

REST:

GraphQL:

Interview Question: “When would you choose REST over GraphQL?”

REST when: simple CRUD, public APIs (easier for consumers), strong caching needs, team is small. GraphQL when: complex data relationships, mobile clients with bandwidth constraints, multiple frontend clients needing different data shapes.


Authentication

JWT (JSON Web Tokens):

Session-based:

PKCE (Proof Key for Code Exchange):

Used in OAuth 2.0 for public clients (SPAs, mobile apps) where you can’t safely store a client secret.

  1. Client generates a random code_verifier and its SHA-256 hash (code_challenge)
  2. Client sends code_challenge with the authorization request
  3. Auth server returns an authorization code
  4. Client exchanges the code + original code_verifier for tokens
  5. Auth server verifies the verifier matches the challenge

Purbayan’s Angle: “I implemented PKCE-based authentication at fiddle using Better Auth. Understanding the flow at the protocol level, not just calling a library function, helped me debug token refresh issues and implement proper session management.”


API Design

Rate Limiting:

Token bucket algorithm:
- Bucket holds N tokens, refills at rate R per second
- Each request consumes one token
- If bucket is empty, request is rejected (429 Too Many Requests)

Pagination patterns:

Caching strategies:

Purbayan’s Angle: “I built rate limiting into 4at’s TCP server to prevent message spam. The token bucket algorithm was a natural fit because it allows bursts while enforcing an average rate.”


System Design (Interview Round Prep)

The Framework

🎯 Interview Tip: When asked about system design, start with requirements clarification before jumping to architecture. Spend 2-3 minutes here.

Every system design answer should follow this structure:

  1. Clarify requirements (2-3 min)

    • Functional: What does the system do?
    • Non-functional: Scale, latency, availability, consistency
    • Ask: “How many users? Read-heavy or write-heavy? What’s the acceptable latency?”
  2. Estimate scale (2-3 min)

    • Users, requests/second, storage needs
    • Back-of-envelope math: 1M users, 10% daily active, 5 requests/user = 500K requests/day = ~6 req/sec average, ~60 req/sec peak
  3. High-level design (10 min)

    • Draw the main components: clients, load balancer, app servers, database, cache, message queue
    • Show data flow for the primary use case
  4. Deep dive (15-20 min)

    • Pick 2-3 components and go deep
    • Database schema, API design, caching strategy, real-time updates
  5. Tradeoffs and alternatives (5 min)

    • What breaks at 10x scale? 100x?
    • What would you change if consistency mattered more than availability (or vice versa)?

Design a Real-Time Collaborative Drawing Tool

Connect to: Canvas Kit + fiddle experience with tldraw

Requirements:

High-level architecture:

Key decisions:

Purbayan’s Angle: “I worked with tldraw at fiddle for component previews. I understand the canvas rendering pipeline, the shape model, and how tldraw handles collaborative editing internally. I’d build on that experience.”


Design a Chat System

Connect to: 4at TCP server

Requirements:

Architecture:

Scaling considerations:

Purbayan’s Angle: “I built 4at as a TCP chat server in Rust. Scaling it up means replacing the single-server broadcast with a distributed pub/sub system, adding message persistence, and handling the connection routing problem. The core concepts (message framing, connection lifecycle, concurrent client handling) are the same.”


Design a Local Search Engine

Connect to: seroost

Requirements:

Architecture:

Making it distributed (interview extension):

Purbayan’s Angle: “Seroost is my local search engine built from scratch. I implemented TF-IDF scoring, inverted indexing, and tokenization. To make it distributed, I’d shard the index across nodes and add a query coordinator, similar to how Elasticsearch distributes its Lucene shards.”


Design a Component Preview System

Connect to: fiddle

Requirements:

Architecture:


Design a URL Shortener

Requirements:

Architecture:

Key decision: 301 vs 302 redirect?


Design a Real-Time Notification System

Requirements:

Architecture:


AI Integration & Modern Development

How to Use AI in Development

AI is a coding partner, not a replacement. The best developers in 2026 use AI to:

What AI is NOT good at:


How to Verify AI Output

  1. Read every line. Don’t copy-paste blindly. Understand what the code does.
  2. Run the tests. If there are no tests, write them first.
  3. Check for hallucinated APIs. AI invents function signatures that don’t exist.
  4. Profile performance. AI-generated code often works but isn’t optimal.
  5. Review security. Check for SQL injection, XSS, exposed secrets, improper auth checks.

LLM API Integration

// Streaming response from Claude API
import Anthropic from "@anthropic-ai/sdk"

const client = new Anthropic()

async function streamResponse(prompt: string) {
  const stream = await client.messages.stream({
    model: "claude-sonnet-4-20250514",
    max_tokens: 1024,
    messages: [{ role: "user", content: prompt }],
  })

  for await (const event of stream) {
    if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
      process.stdout.write(event.delta.text)
    }
  }
}

Key considerations:

Purbayan’s Angle: “At fiddle, I worked with Claude API health checks and monitoring. I understand the operational side of LLM integration: latency budgets, error rates, fallback strategies.”


When AI Fails

Hallucinations: AI confidently generates code using APIs that don’t exist, or cites documentation that was never written. Always verify against official docs.

Security blind spots: AI-generated code might:

Over-reliance trap: If you can’t write the code without AI, you can’t debug it when AI gets it wrong. Maintain your fundamentals.


Interview Question: “How do you use AI tools in your workflow?”

Model answer for Purbayan:

“I use AI as an accelerator, not a crutch. For scaffolding, I’ll describe the component structure I want and let AI generate the boilerplate, then I review and adjust. For debugging, I’ll paste the error with context and use AI to narrow down the cause, but I always verify the fix myself.

Where I draw the line: I don’t let AI make architectural decisions. When I built seroost, I implemented TF-IDF from the math, not from an AI-generated snippet, because I needed to understand the algorithm deeply enough to optimize it. Same with the FFT in musializer.

The skill isn’t using AI. Everyone can do that. The skill is knowing when AI output is wrong, knowing when to go deeper than what AI suggests, and maintaining the fundamentals so you can work without it when needed.”


Behavioral Questions (STAR Method)

For each question: Situation, Task, Action, Result.

”Tell me about a challenging bug you fixed.”

Situation: At fiddle, the canvas component (built with tldraw) would intermittently fail to load. Users would see a blank white area instead of the component preview.

Task: Identify and fix the root cause. The bug was intermittent, which made it harder to reproduce.

Action: I traced the issue to a race condition between tldraw’s lifecycle initialization and React Query’s cache invalidation. When the component mounted, tldraw needed the editor instance to be fully initialized before receiving shape data. But React Query would sometimes return cached data before tldraw was ready, and other times it would fetch fresh data (slower), giving tldraw time to initialize. The fix was sequencing the initialization: wait for tldraw’s onMount callback before feeding it data from React Query.

Result: The canvas loading became 100% reliable. I also added a loading skeleton so users saw feedback during initialization instead of a blank area.


”Describe a time you had to learn something quickly.”

Situation: At fiddle, the team decided to migrate the component preview canvas from react-flow to tldraw. I had no experience with tldraw.

Task: Get up to speed on tldraw’s API, data model, and rendering pipeline quickly enough to implement the migration.

Action: I read tldraw’s source code (it’s open source), built small prototypes to understand the shape system and camera controls, and documented the key differences from react-flow. Within a week, I had a working prototype of the component preview using tldraw’s custom shape API.

Result: The migration shipped successfully. The tldraw-based canvas was more performant and gave us features (infinite canvas, built-in collaboration support) that react-flow couldn’t provide.


”How do you handle working with unfamiliar codebases?”

Situation: When I joined fiddle-factory, I had to understand six interconnected repositories: the main app, the component library, the canvas engine, the API layer, the CLI tool, and the documentation site.

Task: Become productive without slowing down the team with constant questions.

Action: I started by reading the README and architecture docs (where they existed). Then I traced the data flow for one complete user journey: “user creates a component” from the UI click through the API to the database and back. I took notes on each repo’s responsibility and the interfaces between them. I asked targeted questions only after I’d done my own investigation.

Result: Within two weeks, I was shipping PRs across multiple repos. The approach of tracing a single user journey end-to-end gave me a mental model of the whole system faster than trying to understand each repo in isolation.


”Tell me about a project you’re proud of.”

Option A: musializer

“I built a music visualizer in C where I implemented the Fast Fourier Transform from the mathematical definition. Not from a library, not from a tutorial. I read the math, understood the butterfly operations, and wrote the code. The program reads audio samples, applies windowing, runs FFT, and renders a real-time frequency visualization. I’m proud of it because it proves I can go from theory to implementation, which is rare in web development.”

Option B: seroost

“I built a local search engine from scratch. TF-IDF scoring, inverted indexing, tokenization. All implemented by hand. It indexes files on your machine and returns ranked search results in milliseconds. I’m proud of it because search is one of those things everyone uses but few people understand at the implementation level."


"How do you prioritize between competing tasks?”

Situation: At fiddle, I often had multiple PRs in review, new feature requests, and bug reports landing simultaneously.

Task: Ship the most impactful work without letting anything fall through the cracks.

Action: I prioritized by impact and urgency. Production bugs first (users are affected now). Then PR reviews (unblocking teammates). Then feature work (important but not urgent). I communicated my priorities in standup so the team knew what to expect. When I couldn’t do everything, I said so explicitly rather than silently dropping tasks.

Result: Consistent delivery without burnout. The team could predict my output, which made planning easier.


”Describe how you handle disagreements in code review.”

Situation: I contributed to Apache ECharts (open source charting library). My PR changed the tooltip positioning logic, and a maintainer disagreed with my approach.

Task: Resolve the disagreement constructively and get the PR merged.

Action: I re-read their feedback carefully. They had a valid point about edge cases I hadn’t considered. Instead of defending my original approach, I acknowledged the gap, proposed a revised solution that addressed their concern, and explained my reasoning with code examples. I also added test cases for the edge cases they identified.

Result: The PR was merged after the revision. The maintainer thanked me for being receptive to feedback. Open source taught me that code review disagreements are about the code, not about ego.


”Why are you switching from Mechanical Engineering to Software?”

“I’m not switching away from engineering. I’m switching to a different kind of engineering. The curiosity that drove me to study mechanical systems is the same curiosity that drives me to build software.

The difference is that in software, I can go from idea to working prototype in hours, not months. I can build things that people actually use. And the depth is unlimited. I’ve built a TCP server, implemented FFT from math, created a search engine from scratch, and shipped production features at a startup. None of that required a CS degree. It required curiosity and the willingness to go deep.

My engineering background isn’t a weakness. It’s why I think in systems, why I measure before optimizing, and why I approach problems with rigor instead of guesswork.”


Coding Round Prep Strategy

Approach

Purbayan has a Go DSA repository with fundamental implementations. The goal isn’t to memorize solutions but to recognize patterns.

Focus Areas (by frequency in interviews)

  1. Arrays & Strings — Two pointers, sliding window, prefix sums, hash maps
  2. Trees & Graphs — BFS, DFS, binary search trees, topological sort
  3. Dynamic Programming — Memoization, tabulation, common patterns (knapsack, LCS, coin change)
  4. Sliding Window — Fixed and variable size, with hash maps for character counting
  5. Stack & Queue — Monotonic stack, BFS with queue, parentheses matching

Language Choice

Time Management (45-minute round)

PhaseTimeWhat to Do
Understand5 minRead the problem. Ask clarifying questions. Confirm input/output with examples.
Approach5 minThink out loud. Describe your approach before coding. Mention time/space complexity.
Code20 minWrite clean code. Use meaningful variable names. Handle edge cases.
Test5 minTrace through your code with the examples. Test edge cases (empty input, single element, duplicates).
Optimize5-10 minIf time permits, discuss optimizations. Can you reduce space? Time?

Communication During Coding

This is as important as the code itself:

Common Patterns to Internalize

Two Pointers:

function twoSum(nums: number[], target: number): [number, number] {
  // Assumes sorted array
  let left = 0,
    right = nums.length - 1
  while (left < right) {
    const sum = nums[left] + nums[right]
    if (sum === target) return [left, right]
    if (sum < target) left++
    else right--
  }
  return [-1, -1]
}

Sliding Window:

function maxSubarraySum(nums: number[], k: number): number {
  let windowSum = 0
  for (let i = 0; i < k; i++) windowSum += nums[i]

  let maxSum = windowSum
  for (let i = k; i < nums.length; i++) {
    windowSum += nums[i] - nums[i - k] // slide the window
    maxSum = Math.max(maxSum, windowSum)
  }
  return maxSum
}

BFS (Graph/Tree):

function bfs(graph: Map<string, string[]>, start: string): string[] {
  const visited = new Set<string>()
  const queue: string[] = [start]
  const result: string[] = []

  visited.add(start)
  while (queue.length > 0) {
    const node = queue.shift()!
    result.push(node)
    for (const neighbor of graph.get(node) || []) {
      if (!visited.has(neighbor)) {
        visited.add(neighbor)
        queue.push(neighbor)
      }
    }
  }
  return result
}

Dynamic Programming (Memoization):

function climbStairs(n: number, memo: Map<number, number> = new Map()): number {
  if (n <= 2) return n
  if (memo.has(n)) return memo.get(n)!

  const result = climbStairs(n - 1, memo) + climbStairs(n - 2, memo)
  memo.set(n, result)
  return result
}

Last updated: February 2026. Review and update quarterly as the landscape evolves.