Web Engineering ·

OAuth 2.0 and OpenID Connect in Practice: Flows, PKCE, and Token Management in TypeScript

OAuth 2.0 is a delegation protocol, not an authentication protocol. This covers why both exist, the Authorization Code Flow with PKCE for SPAs and server apps, TypeScript implementation of token exchange and ID token validation, refresh token rotation, common security mistakes including implicit flow and localStorage, session management post-login, and a concrete guide to hosted providers vs rolling your own.

OAuth 2.0 and OpenID Connect in Practice: Flows, PKCE, and Token Management in TypeScript

Most authentication bugs come from one misunderstanding: OAuth 2.0 does not tell you who the user is. It tells you that a user granted a client permission to act on their behalf. The identity part is OpenID Connect’s job. Conflating the two leads to systems that accept OAuth access tokens as proof of identity, skip ID token validation, or trust claims in tokens that have not been verified against a public key.

This article covers both protocols as they work in practice: the Authorization Code Flow with PKCE, TypeScript implementations of the full token exchange and ID token validation, refresh token rotation, common mistakes and why they matter, session management after an OIDC login, and a framework for deciding when to use a hosted provider versus implementing the flow yourself.

The code is TypeScript. The observations come from systems that have run in production.

Why OAuth 2.0 and OpenID Connect Exist

Before OAuth, the dominant pattern for “log in with another service” was credential sharing: give the third-party app your username and password, and it would log in as you. This was catastrophic. The third party stored your credentials, had full account access with no scope limitation, and you had no way to revoke access without changing your password.

OAuth 2.0 replaced credential sharing with token-based delegation. Instead of handing over credentials, the user authorizes a specific client (your app) to access specific resources (scopes) on their behalf. The authorization server issues a time-limited, scoped access token. Revocation is independent of credentials.

But OAuth 2.0 tokens say nothing about identity. An access token proves that someone authorized your app to call, say, /user/profile on GitHub. It does not prove which user. A malicious server could replay a valid access token issued to a different app and your system would accept it.

OpenID Connect (OIDC) adds the identity layer on top of OAuth 2.0. It defines an id_token: a signed JWT issued by the authorization server that contains verified identity claims (sub, email, aud, iss, iat, exp). Your app validates the ID token’s signature against the provider’s public keys and verifies the aud claim matches your client ID. That is the authentication step OAuth alone cannot provide.

The protocol separation matters for implementation: use the access token to call APIs, use the ID token to establish user identity, and validate them differently.

The Authorization Code Flow with PKCE

The Authorization Code Flow is the correct flow for both server-rendered apps and SPAs. It keeps tokens off the URL and out of browser history. PKCE (Proof Key for Code Exchange) is a required extension for public clients (SPAs, native apps) and strongly recommended for confidential clients (server-side apps).

The flow in five steps:

  1. Your app generates a random code_verifier, hashes it to produce a code_challenge, and stores the verifier locally.
  2. Your app redirects the user to the authorization server with the code_challenge and a state parameter.
  3. The user authenticates at the provider and approves the scopes.
  4. The provider redirects back with an authorization code (short-lived, single-use).
  5. Your app exchanges the code and code_verifier for tokens at the token endpoint.

The authorization code is useless without the code_verifier. Even if an attacker intercepts the callback URL (via referrer headers, browser extensions, or shared redirect URIs), they cannot complete the exchange. That is the specific threat PKCE addresses.

Generating the PKCE Parameters and State

// lib/pkce.ts
import { randomBytes, createHash } from 'crypto'

export interface PKCEParams {
  codeVerifier: string
  codeChallenge: string
  state: string
}

export function generatePKCEParams(): PKCEParams {
  // Must be 43-128 characters of unreserved URL chars
  const codeVerifier = randomBytes(32).toString('base64url')

  // S256: BASE64URL(SHA256(ASCII(code_verifier)))
  const codeChallenge = createHash('sha256')
    .update(codeVerifier)
    .digest('base64url')

  // Opaque value for CSRF protection of the OAuth flow itself
  const state = randomBytes(16).toString('hex')

  return { codeVerifier, codeChallenge, state }
}

export function buildAuthorizationUrl(
  params: PKCEParams,
  redirectUri: string
): string {
  const query = new URLSearchParams({
    client_id: process.env.OIDC_CLIENT_ID!,
    redirect_uri: redirectUri,
    response_type: 'code',
    scope: 'openid email profile',
    code_challenge: params.codeChallenge,
    code_challenge_method: 'S256',
    state: params.state,
    // Request offline access for refresh tokens
    access_type: 'offline',
    prompt: 'consent',
  })

  return `${process.env.OIDC_ISSUER}/authorize?${query}`
}

Before redirecting, persist codeVerifier and state in a short-lived, httpOnly, sameSite: lax cookie. Do not store them in sessionStorage or localStorage; if an XSS payload runs before the callback, it can steal the verifier.

// In a Next.js route handler: GET /api/auth/login
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { generatePKCEParams, buildAuthorizationUrl } from '@/lib/pkce'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const returnTo = searchParams.get('returnTo') ?? '/dashboard'

  const pkce = generatePKCEParams()
  const redirectUri = `${process.env.BASE_URL}/api/auth/callback`

  const authUrl = buildAuthorizationUrl(pkce, redirectUri)

  const response = NextResponse.redirect(authUrl)

  const cookieOpts = {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax' as const,
    maxAge: 60 * 10, // 10 minutes, enough to complete the flow
    path: '/',
  }

  response.cookies.set('pkce_verifier', pkce.codeVerifier, cookieOpts)
  response.cookies.set('oauth_state', pkce.state, cookieOpts)
  response.cookies.set('return_to', returnTo, { ...cookieOpts, httpOnly: false })

  return response
}

The Callback: Token Exchange and ID Token Validation

When the provider redirects back with an authorization code, your callback handler must:

  1. Verify state matches what was stored (CSRF check).
  2. Exchange the code for tokens, sending the code_verifier.
  3. Validate the ID token signature and claims.
  4. Extract the user identity from the validated ID token.
  5. Create a session and redirect.
// lib/oidc.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'

const JWKS = createRemoteJWKSet(
  new URL(`${process.env.OIDC_ISSUER}/.well-known/jwks.json`)
)

export interface OIDCClaims extends JWTPayload {
  sub: string
  email: string
  email_verified: boolean
  name?: string
  picture?: string
  // aud is validated by jwtVerify against audience option
}

export async function exchangeCodeForTokens(
  code: string,
  codeVerifier: string,
  redirectUri: string
): Promise<{ idToken: string; accessToken: string; refreshToken?: string }> {
  const response = await fetch(`${process.env.OIDC_ISSUER}/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: redirectUri,
      client_id: process.env.OIDC_CLIENT_ID!,
      client_secret: process.env.OIDC_CLIENT_SECRET!,
      code_verifier: codeVerifier,
    }),
  })

  if (!response.ok) {
    const error = await response.text()
    throw new Error(`Token exchange failed: ${response.status} ${error}`)
  }

  const body = await response.json()

  return {
    idToken: body.id_token,
    accessToken: body.access_token,
    refreshToken: body.refresh_token,
  }
}

export async function validateIdToken(idToken: string): Promise<OIDCClaims> {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: process.env.OIDC_ISSUER!,
    audience: process.env.OIDC_CLIENT_ID!,
  })

  // jwtVerify verifies: signature, exp, nbf, iss, aud
  // Verify additional OIDC-specific requirements
  if (!payload.sub) {
    throw new Error('ID token missing sub claim')
  }

  if (typeof payload.iat !== 'number') {
    throw new Error('ID token missing iat claim')
  }

  // iat should be recent (within 5 minutes) to prevent replay of old tokens
  const nowSeconds = Math.floor(Date.now() / 1000)
  if (nowSeconds - payload.iat > 300) {
    throw new Error('ID token iat too old')
  }

  return payload as OIDCClaims
}

Using jose with createRemoteJWKSet handles JWKS caching and key rotation automatically. The library fetches the provider’s public keys, caches them, and refreshes when a kid (key ID) is not found in the local cache. This is the correct behavior: you do not want to hardcode public keys and manually manage rotation.

The jwtVerify call validates signature, expiry, iss, and aud. What it does not validate is the iat freshness check above. In a web flow the token will always be fresh, but the explicit check is a defense against ID tokens that somehow arrive out of band.

// app/api/auth/callback/route.ts
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { exchangeCodeForTokens, validateIdToken } from '@/lib/oidc'
import { upsertUser } from '@/lib/users'
import { createSession } from '@/lib/session'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const code = searchParams.get('code')
  const state = searchParams.get('state')
  const error = searchParams.get('error')

  if (error) {
    return NextResponse.redirect(`/login?error=${encodeURIComponent(error)}`)
  }

  const cookieStore = await cookies()
  const storedState = cookieStore.get('oauth_state')?.value
  const codeVerifier = cookieStore.get('pkce_verifier')?.value
  const returnTo = cookieStore.get('return_to')?.value ?? '/dashboard'

  if (!code || !state || !storedState || !codeVerifier) {
    return NextResponse.redirect('/login?error=missing_params')
  }

  // State mismatch means a potential CSRF attempt: reject loudly
  if (state !== storedState) {
    return NextResponse.redirect('/login?error=state_mismatch')
  }

  const redirectUri = `${process.env.BASE_URL}/api/auth/callback`

  const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri)
  const claims = await validateIdToken(tokens.idToken)

  // Upsert user (create on first login, update on subsequent logins)
  const user = await upsertUser({
    providerId: claims.sub,
    email: claims.email,
    emailVerified: claims.email_verified,
    name: claims.name,
  })

  // Create your own session. Do not pass the OIDC tokens to the browser.
  const sessionId = await createSession(
    { userId: user.id, email: user.email, roles: user.roles },
    user.id
  )

  const response = NextResponse.redirect(returnTo)

  response.cookies.set('session_id', sessionId, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
    path: '/',
  })

  // Clean up the ephemeral OAuth cookies
  response.cookies.delete('pkce_verifier')
  response.cookies.delete('oauth_state')
  response.cookies.delete('return_to')

  return response
}

The important decision in the callback: after validating the ID token, create your own session rather than forwarding OIDC tokens to the browser. The OIDC access token grants access to the provider’s APIs, not yours. Your session is what grants access to your application. Mixing them up creates confused deputy problems.

Refresh Token Rotation

If you requested offline_access (or equivalent), the provider returns a refresh token. Refresh tokens are long-lived credentials that can obtain new access tokens without user interaction. They deserve the same care as passwords.

The critical property to enforce: each refresh token is single-use. When a client presents a refresh token, issue a new one and invalidate the old one. If an already-used refresh token is presented (reuse detection), invalidate the entire token family and force re-authentication.

// lib/refresh.ts
import { createHash } from 'crypto'

interface RefreshTokenRecord {
  id: string
  userId: string
  tokenHash: string
  parentHash: string | null
  createdAt: Date
  usedAt: Date | null
  revokedAt: Date | null
}

function hashToken(token: string): string {
  return createHash('sha256').update(token).digest('hex')
}

export async function rotateRefreshToken(
  db: Database,
  incomingToken: string
): Promise<{
  newAccessToken: string
  newRefreshToken: string
  userId: string
} | null> {
  const incomingHash = hashToken(incomingToken)
  const record = await db.refreshTokens.findByHash(incomingHash)

  if (!record || record.revokedAt) {
    return null
  }

  // Reuse detection: this token has already been consumed
  if (record.usedAt !== null) {
    // Compromised token family. Invalidate everything for this user.
    await db.refreshTokens.revokeAllForUser(record.userId, 'reuse_detected')
    return null
  }

  // Mark as used in the same transaction as issuing the new one
  await db.transaction(async (tx) => {
    await tx.refreshTokens.markUsed(record.id)
    // The new token will be stored by the caller after receiving it
  })

  const user = await db.users.findById(record.userId)
  if (!user) return null

  const newAccessToken = await signAccessToken({
    sub: user.id,
    email: user.email,
    roles: user.roles,
  })

  const newRefreshToken = generateSecureToken()

  await db.refreshTokens.create({
    userId: user.id,
    tokenHash: hashToken(newRefreshToken),
    parentHash: incomingHash,
    createdAt: new Date(),
    usedAt: null,
    revokedAt: null,
  })

  return { newAccessToken, newRefreshToken, userId: user.id }
}

function generateSecureToken(): string {
  return randomBytes(32).toString('base64url')
}

Store only the hash of the refresh token, never the plaintext. If your database is compromised, hashed tokens require an additional preimage attack to exploit. Plaintext tokens are immediately usable.

The parentHash column is for auditability and family-based revocation. When you detect reuse of token A, you can trace the entire chain (A was used to issue B, B was used to issue C) and revoke all of them.

Common Security Mistakes

The Implicit Flow

The Implicit Flow was designed for SPAs before PKCE existed. Instead of returning an authorization code, the provider returns tokens directly in the URL fragment (#access_token=...). This was always a compromise, not a feature.

URL fragments appear in browser history, are logged by browser extensions, and can leak via the Referer header when loading images or scripts from third parties. The fragment was supposed to be safer because it is not sent to servers, but that assumption relies on no JavaScript reading window.location.hash, no browser extensions logging navigation events, and no redirects within the SPA that might log the full URL.

PKCE makes Implicit Flow obsolete. There is no reason to use it for new applications. The Authorization Code Flow with PKCE has the same round-trip count for SPAs and is substantially safer. The OAuth 2.0 Security Best Current Practice explicitly recommends against Implicit Flow.

Tokens in localStorage

Storing access tokens or refresh tokens in localStorage exposes them to every piece of JavaScript running on your page, including third-party scripts (analytics, chat widgets, A/B testing tools). An XSS vulnerability in any dependency, or in your own code, means the attacker can exfiltrate tokens with localStorage.getItem('access_token').

httpOnly cookies are not accessible from JavaScript at all. They are attached by the browser on each request. The attack surface for token theft collapses to CSRF, which sameSite and (if needed) explicit CSRF tokens handle.

The common counterargument is “but my API and frontend are on different domains.” That is a deployment problem, not a reason to accept permanent XSS exposure. Reverse proxies and same-origin deployments are the correct solution. Alternatively, use the BFF (Backend for Frontend) pattern: the SPA calls your own backend, which holds the tokens server-side and proxies requests to downstream APIs. The browser never sees a token.

Missing State Parameter

The state parameter is CSRF protection for the OAuth flow itself. Without it, an attacker can initiate an OAuth flow in the victim’s browser (by tricking them to visit a crafted URL), capture the authorization code, and then trick the victim into completing the attacker’s flow, which would link the victim’s account to the attacker’s identity at the provider.

The state must be a cryptographically random value stored server-side (or in a signed cookie) before the redirect. On callback, the incoming state must match exactly. Reject mismatches with a hard error, not a logged warning.

Accepting the Access Token as Identity Proof

This is the most conceptually wrong mistake. When a user logs in via OIDC, you receive both an ID token and an access token. The access token is for calling the provider’s APIs (Google’s People API, GitHub’s user endpoint, etc.). It is a bearer credential for the provider’s resource server.

Calling GET https://www.googleapis.com/oauth2/v3/userinfo with the access token and trusting the response as authentication is not OIDC authentication. It is an API call. The response could come from a proxy. The token is not scoped to your app.

The ID token is signed by the provider’s private key and contains your client_id in the aud claim. Validating the ID token against the provider’s JWKS, with aud checked against your client ID, is the correct authentication step. Do that.

Session Management After OIDC Login

After the callback completes and the ID token is validated, you should own the session entirely. The OIDC tokens served their purpose: proving identity. Your session (Redis-backed, database-backed, or a signed session cookie) is what governs authorization within your application from this point.

// lib/session.ts
import { createClient } from 'redis'
import { randomBytes } from 'crypto'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

interface SessionData {
  userId: string
  email: string
  roles: string[]
  createdAt: number
  lastActiveAt: number
  // Track how the session was established, useful for step-up auth
  authMethod: 'password' | 'oidc'
  oidcProvider?: string
}

const SESSION_TTL = 60 * 60 * 24 * 7 // 7 days

export async function createSession(
  data: Omit<SessionData, 'createdAt' | 'lastActiveAt'>,
  userId: string
): Promise<string> {
  const sessionId = randomBytes(32).toString('base64url')

  const sessionData: SessionData = {
    ...data,
    createdAt: Date.now(),
    lastActiveAt: Date.now(),
  }

  const pipeline = redis.multi()
  pipeline.setEx(`session:${sessionId}`, SESSION_TTL, JSON.stringify(sessionData))
  pipeline.sAdd(`user-sessions:${userId}`, sessionId)
  pipeline.expire(`user-sessions:${userId}`, SESSION_TTL)
  await pipeline.exec()

  return sessionId
}

export async function getSession(sessionId: string): Promise<SessionData | null> {
  const raw = await redis.get(`session:${sessionId}`)
  if (!raw) return null

  // Sliding expiry: active sessions stay alive
  await redis.expire(`session:${sessionId}`, SESSION_TTL)

  return JSON.parse(raw) as SessionData
}

export async function revokeAllUserSessions(userId: string): Promise<void> {
  const sessionIds = await redis.sMembers(`user-sessions:${userId}`)

  if (sessionIds.length === 0) return

  const pipeline = redis.multi()
  for (const id of sessionIds) {
    pipeline.del(`session:${id}`)
  }
  pipeline.del(`user-sessions:${userId}`)
  await pipeline.exec()
}

One practical consideration: when a user logs in via OIDC, you might want to track which provider was used. This enables step-up authentication (“this action requires you to re-authenticate with your SSO provider”) and supports future account linking or provider migration without breaking session continuity.

Absolute session expiry (not just sliding) is worth implementing for sensitive applications. A session that was created 90 days ago but was active yesterday is still 90 days old. For compliance-sensitive contexts (finance, healthcare, internal tools with elevated access), an absolute TTL that forces re-authentication regardless of activity is the right default.

Hosted Provider vs Rolling Your Own

ConcernHosted Provider (Auth0, Clerk, Cognito)Custom Implementation
Time to productionHoursDays to weeks
OIDC spec complianceMaintained by vendorYour responsibility
Key rotationAutomaticManual process or custom tooling
MFA, passwordless, passkeysIncludedBuild or integrate separately
Token endpoint securityVendor’s problemYour problem
User management UIIncludedBuild it
Monthly Active User pricingGrows with scaleFixed infrastructure cost
Data residencyDepends on vendor planFull control
SAML / enterprise SSOUsually availableSignificant implementation effort
Audit logsVendor dashboardYour logging infrastructure
CustomizationBounded by vendor’s modelUnlimited

The honest version: for most applications, especially early-stage ones, using a hosted provider is the right call. The OIDC specification is substantial. Correctly implementing token endpoint security, JWKS rotation, and all the edge cases in the Authorization Code Flow is non-trivial work. Providers have done this work and have security teams watching for spec changes.

The reasons to roll your own are real but narrow:

Cost at scale. Hosted provider pricing scales with Monthly Active Users. At 500k+ MAU the economics often favor running your own auth service on fixed infrastructure.

Data residency and sovereignty. If you are in a regulated industry where user data cannot leave a specific region, or where vendor subprocessors are not acceptable under your compliance framework, hosted providers may not fit.

Integration requirements. If you need to integrate with an internal directory service (LDAP, Active Directory) or existing enterprise systems in ways the vendor does not support, custom implementation is necessary.

Full-stack ownership. If your team already runs identity infrastructure and auth is a core competency, the vendor dependency may not be worth the cost savings.

If you do implement your own OIDC provider (as opposed to being an OIDC client, which is what this article covers), look at established server libraries like node-oidc-provider. Do not write an OIDC server from scratch.

Production Considerations

JWKS caching and timeout. createRemoteJWKSet from jose caches keys and handles cache invalidation. Make sure the process running your app has a reasonable outbound HTTP timeout configured. If the JWKS endpoint is unreachable and the cache is empty, token validation will fail. That is the correct behavior: fail closed, not open. Pair it with an alert on auth failure spikes.

Clock skew. The exp and iat claims in JWTs are compared against the current time. If your server clock drifts, valid tokens will be rejected or expired tokens will be accepted. NTP synchronization is standard, but build in a small tolerance (60 seconds) for nbf and iat checks to handle minor skew.

Redirect URI validation. Register exact redirect URIs with your provider, not wildcard patterns. An attacker who can register https://yourdomain.com/api/auth/callback?injected=true as a redirect URI can craft authorization URLs that redirect to unexpected endpoints. Most providers allow exact-match only, but verify this in your configuration.

The nonce claim for replay protection. In web flows, the nonce parameter adds replay protection for ID tokens. Generate a random nonce, include it in the authorization request, store it server-side, and verify the nonce claim in the ID token matches on callback. This prevents an attacker from capturing an ID token and replaying it in a different session context. jose’s jwtVerify accepts a clockTolerance and can validate nonce if you pass it in the options.

// Add nonce to the authorization URL and ID token validation
export async function validateIdToken(
  idToken: string,
  expectedNonce: string
): Promise<OIDCClaims> {
  const { payload } = await jwtVerify(idToken, JWKS, {
    issuer: process.env.OIDC_ISSUER!,
    audience: process.env.OIDC_CLIENT_ID!,
    clockTolerance: 60,
  })

  if (payload.nonce !== expectedNonce) {
    throw new Error('ID token nonce mismatch')
  }

  return payload as OIDCClaims
}

Logout. OIDC defines an end session endpoint (/logout). Calling it terminates the session at the provider, which is necessary for SSO logout (logging out of your app also logs the user out of all other apps in the SSO circle). Local logout (clearing your own session cookie) without calling the provider’s end session endpoint leaves the provider session active, so a user who clicks “log out” can immediately click “log in” and get back in without entering credentials. Whether that is acceptable depends on the security requirements of your application.


The distinction between OAuth 2.0 and OIDC is not academic. It determines which token you validate, how you validate it, and what guarantees you actually have about the user’s identity. Getting the Authorization Code Flow right, enforcing PKCE, and building a clean session boundary after callback are the foundational pieces. The rest is operational: key rotation, clock skew, logout semantics, and the economics of hosted versus self-managed. Build the foundation correctly and the operational concerns become tractable.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.