Why Logging and Debugging Matter
Every application breaks. The difference between a 5-minute fix and a 5-hour investigation is how well you log and how effectively you debug.
Logging tells you what happened. Debugging tells you why. Together, they're your primary tools for understanding and fixing software in development and production.
Console Logging Best Practices
The console object is every developer's first debugging tool. Most developers only use console.log, but there's much more available.
Beyond console.log
// Basic logging
console.log('User logged in:', userId);
// Warnings -- something unexpected but not broken
console.warn('API response took 3s, expected < 1s');
// Errors -- something is broken
console.error('Failed to fetch user:', error.message);
// Informational -- general flow information
console.info('Server started on port 3000');
// Debug -- verbose, hidden by default in most browsers
console.debug('Cache hit for key:', cacheKey);
Formatting and Organization
// Group related logs
console.group('User Authentication');
console.log('Email:', email);
console.log('Method:', 'OAuth');
console.log('Provider:', 'Google');
console.groupEnd();
// Collapsed group (starts closed)
console.groupCollapsed('Request Details');
console.log('URL:', url);
console.log('Method:', method);
console.log('Headers:', headers);
console.groupEnd();
// Table for structured data
const users = [
{ name: 'Alice', role: 'admin', active: true },
{ name: 'Bob', role: 'user', active: false },
{ name: 'Charlie', role: 'user', active: true },
];
console.table(users);
// Output:
// +-------+---------+-------+--------+
// | Index | name | role | active |
// +-------+---------+-------+--------+
// | 0 | Alice | admin | true |
// | 1 | Bob | user | false |
// | 2 | Charlie | user | true |
// +-------+---------+-------+--------+
// Timing operations
console.time('database-query');
await db.query('SELECT * FROM users');
console.timeEnd('database-query');
// Output: database-query: 45.123ms
// Count occurrences
function handleClick() {
console.count('button-clicked');
}
// button-clicked: 1
// button-clicked: 2
// button-clicked: 3
// Assert -- only logs when condition is false
console.assert(user !== null, 'User should not be null at this point');
// Styled console output
console.log(
'%cERROR%c Payment failed for order %s',
'background: red; color: white; padding: 2px 6px; border-radius: 3px;',
'color: inherit;',
orderId
);
When to Remove console.log
Development logs should never reach production. Use linting to enforce this.
// .eslintrc.json
{
"rules": {
"no-console": ["warn", { "allow": ["warn", "error"] }]
}
}
This warns on console.log and console.debug but allows console.warn and console.error.
Structured Logging
Unstructured logs are human-readable but machine-unfriendly. Structured logging outputs JSON, making logs searchable, filterable, and parseable.
Unstructured vs Structured
Unstructured:
[2026-05-08 14:32:01] ERROR: Failed to process order #12345 for user [email protected] - Payment declined
Structured (JSON):
{
"timestamp": "2026-05-08T14:32:01.123Z",
"level": "error",
"message": "Failed to process order",
"orderId": "12345",
"userId": "[email protected]",
"reason": "payment_declined",
"paymentMethod": "visa",
"amount": 99.99,
"service": "order-processor",
"traceId": "abc-123-def-456"
}
The structured version is searchable: find all orders that failed due to payment decline, filter by user, correlate with a trace ID.
Implementing Structured Logging
// src/lib/logger.ts
type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal';
type LogEntry = {
timestamp: string;
level: LogLevel;
message: string;
service: string;
[key: string]: unknown;
};
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
warn: 2,
error: 3,
fatal: 4,
};
const currentLevel = (process.env.LOG_LEVEL as LogLevel) || 'info';
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
}
function log(level: LogLevel, message: string, meta: Record<string, unknown> = {}) {
if (!shouldLog(level)) return;
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
message,
service: process.env.SERVICE_NAME || 'app',
...meta,
};
const output = JSON.stringify(entry);
if (level === 'error' || level === 'fatal') {
process.stderr.write(output + '\n');
} else {
process.stdout.write(output + '\n');
}
}
export const logger = {
debug: (msg: string, meta?: Record<string, unknown>) => log('debug', msg, meta),
info: (msg: string, meta?: Record<string, unknown>) => log('info', msg, meta),
warn: (msg: string, meta?: Record<string, unknown>) => log('warn', msg, meta),
error: (msg: string, meta?: Record<string, unknown>) => log('error', msg, meta),
fatal: (msg: string, meta?: Record<string, unknown>) => log('fatal', msg, meta),
};
Using the Logger
import { logger } from './lib/logger';
// API request logging
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
logger.info('HTTP request', {
method: req.method,
path: req.path,
statusCode: res.statusCode,
durationMs: Date.now() - start,
userAgent: req.headers['user-agent'],
ip: req.ip,
});
});
next();
});
// Business logic logging
async function processOrder(orderId: string, userId: string) {
logger.info('Processing order', { orderId, userId });
try {
const result = await chargePayment(orderId);
logger.info('Payment successful', {
orderId,
userId,
amount: result.amount,
transactionId: result.transactionId,
});
} catch (error) {
logger.error('Payment failed', {
orderId,
userId,
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
throw error;
}
}
// Startup logging
logger.info('Server started', {
port: 3000,
environment: process.env.NODE_ENV,
nodeVersion: process.version,
});
Log Levels
Log levels let you control verbosity. In production, you want info and above. In development, you might want debug.
+-------+------------------------------------------------------------------+
| Level | Purpose |
+-------+------------------------------------------------------------------+
| DEBUG | Detailed diagnostic info. Variables, state, flow tracing. |
| | Example: "Cache lookup for key user:123 โ miss" |
+-------+------------------------------------------------------------------+
| INFO | General operational events. Things working as expected. |
| | Example: "Server started on port 3000" |
+-------+------------------------------------------------------------------+
| WARN | Something unexpected but not broken. May need attention. |
| | Example: "API response took 5s, threshold is 2s" |
+-------+------------------------------------------------------------------+
| ERROR | Something failed. An operation could not complete. |
| | Example: "Failed to send email: SMTP connection refused" |
+-------+------------------------------------------------------------------+
| FATAL | Application cannot continue. Process will exit. |
| | Example: "Database connection failed after 10 retries. Exiting" |
+-------+------------------------------------------------------------------+
Environment settings:
Development: LOG_LEVEL=debug (see everything)
Staging: LOG_LEVEL=debug (catch issues before production)
Production: LOG_LEVEL=info (operational events + errors)
Debugging: LOG_LEVEL=debug (temporarily, then revert)
What to Log at Each Level
// DEBUG: detailed diagnostic information
logger.debug('Query plan', { sql: query, params, executionPlan });
logger.debug('Cache lookup', { key, hit: false, ttl: 300 });
logger.debug('JWT decoded', { userId, roles, expiresAt });
// INFO: operational events
logger.info('User registered', { userId, email, method: 'email' });
logger.info('Order created', { orderId, items: 3, total: 149.99 });
logger.info('Deployment started', { version: '2.1.0', environment: 'production' });
// WARN: unexpected but handled
logger.warn('Rate limit approaching', { userId, requestCount: 90, limit: 100 });
logger.warn('Deprecated API called', { endpoint: '/api/v1/users', alternative: '/api/v2/users' });
logger.warn('Retry attempt', { operation: 'sendEmail', attempt: 2, maxRetries: 3 });
// ERROR: operation failed
logger.error('Payment processing failed', { orderId, error: err.message, provider: 'stripe' });
logger.error('File upload failed', { fileName, size, error: err.message });
// FATAL: application cannot continue
logger.fatal('Database connection lost', { host, port, retries: 10 });
logger.fatal('Required config missing', { missing: ['DATABASE_URL', 'JWT_SECRET'] });
What NOT to Log
// NEVER log sensitive data
logger.info('User login', { email, password }); // password!
logger.info('Payment', { cardNumber, cvv }); // card details!
logger.info('Request', { headers }); // may contain auth tokens!
// SAFE: redact sensitive fields
logger.info('User login', { email, password: '***' });
logger.info('Payment', { cardLast4: '4242', amount: 99.99 });
logger.info('Request', {
headers: { ...headers, authorization: '[REDACTED]' },
});
Browser DevTools
Chrome DevTools (and equivalents in Firefox and Edge) is the most powerful debugging tool for frontend development.
Console Panel
// Preserve logs across page navigation
// Settings > Preserve log (checkbox)
// Filter by log level
// Click the level buttons: Verbose, Info, Warnings, Errors
// Filter by text
// Type in the filter box: "payment" shows only logs containing "payment"
// Reference DOM elements
// Right-click element > "Store as global variable" -> temp1
// console.dir(temp1) to inspect properties
// Monitor function calls
function calculateTotal(items) { /* ... */ }
monitor(calculateTotal);
// Every call logs: "function calculateTotal called with arguments: ..."
// Monitor events on an element
monitorEvents(document.getElementById('submit-btn'), 'click');
Sources Panel -- Breakpoints
Breakpoints pause execution at a specific line, letting you inspect variables and step through code.
// Debugger statement -- programmatic breakpoint
function processPayment(order) {
debugger; // execution pauses here when DevTools is open
const total = calculateTotal(order.items);
const tax = total * TAX_RATE;
return chargeCard(order.cardToken, total + tax);
}
Types of Breakpoints
Line breakpoint Click the line number in Sources panel
Conditional breakpoint Right-click line number > "Add conditional breakpoint"
Expression: userId === 'problem-user-123'
Logpoint Right-click line number > "Add logpoint"
Logs without pausing: "Order total: {total}"
DOM breakpoint Elements panel > right-click element > "Break on"
Subtree modifications, attribute modifications, node removal
XHR/Fetch breakpoint Sources > XHR/fetch Breakpoints > Add
URL contains: "/api/payments"
Event listener Sources > Event Listener Breakpoints
Check "Mouse > click" to break on all clicks
Exception breakpoint Sources > "Pause on exceptions" button
Breaks when any exception is thrown
Stepping Through Code
When paused at a breakpoint:
+-------------------------------------------+
| Stepping Controls |
+-------------------------------------------+
| Resume (F8) -- continue execution |
| Step Over (F10) -- execute current line, |
| don't enter functions |
| Step Into (F11) -- enter the function |
| on current line |
| Step Out (Shift+F11) -- finish current |
| function, return |
+-------------------------------------------+
function checkout(cart) { // <-- breakpoint here
const subtotal = getSubtotal(cart); // F10: skip into getSubtotal
// F11: step into getSubtotal
const tax = subtotal * 0.08;
const total = subtotal + tax;
return processPayment(total); // F10: skip, F11: enter processPayment
}
Network Panel
The Network panel shows all HTTP requests, their timing, headers, and payloads.
Key information for each request:
Name -- URL of the request
Status -- HTTP status code (200, 404, 500)
Type -- xhr, fetch, script, stylesheet, img
Initiator -- what triggered the request (script, user click)
Size -- response size (transferred vs uncompressed)
Time -- total request time
Waterfall -- visual timing breakdown
Debugging API issues:
1. Click the request to open details
2. Headers tab -- check request method, URL, auth headers
3. Payload tab -- verify the request body is correct
4. Response tab -- see what the server returned
5. Timing tab -- see DNS, connection, TTFB, download time
6. Console tab -- check for CORS errors
Useful Network Panel Features
Filter by type: XHR | JS | CSS | Img | Media | Font | Doc
Filter by text: Type "/api" in filter box
Block requests: Right-click > "Block request URL" (test offline behavior)
Replay request: Right-click > "Replay XHR"
Copy as cURL: Right-click > "Copy as cURL" (test in terminal)
Throttle network: "No throttling" dropdown > Slow 3G, Fast 3G, Offline
Preserve log: Check "Preserve log" to keep requests across navigation
Performance Panel
For debugging slow applications:
1. Click Record
2. Perform the slow action
3. Click Stop
4. Analyze the flame chart
Look for:
- Long tasks (>50ms blocks in the main thread)
- Layout thrashing (forced reflows)
- Excessive re-renders (React DevTools)
- Large JavaScript bundles blocking render
Debugging Node.js Applications
Node Inspector
# Start Node.js with inspector
node --inspect server.js
# Start and break at first line
node --inspect-brk server.js
# Output: Debugger listening on ws://127.0.0.1:9229/abc-123
# Open chrome://inspect in Chrome to connect
Debugging in VS Code
// .vscode/launch.json
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Server",
"program": "${workspaceFolder}/src/server.ts",
"runtimeArgs": ["-r", "ts-node/register"],
"env": {
"NODE_ENV": "development",
"DEBUG": "app:*"
}
},
{
"type": "node",
"request": "launch",
"name": "Debug Tests",
"program": "${workspaceFolder}/node_modules/.bin/vitest",
"args": ["run", "--reporter=verbose"],
"console": "integratedTerminal"
}
]
}
Debug Module
The debug package provides namespaced, toggleable debug logging.
// src/services/auth.ts
import createDebug from 'debug';
const debug = createDebug('app:auth');
export async function login(email: string, password: string) {
debug('Login attempt for %s', email);
const user = await findUser(email);
debug('User found: %o', { id: user?.id, active: user?.active });
if (!user) {
debug('User not found, rejecting');
throw new Error('Invalid credentials');
}
const valid = await verifyPassword(password, user.passwordHash);
debug('Password verification: %s', valid ? 'success' : 'failure');
return generateToken(user);
}
# Enable all app debug logs
DEBUG=app:* node server.js
# Enable specific namespace
DEBUG=app:auth node server.js
# Enable multiple namespaces
DEBUG=app:auth,app:database node server.js
# Disable specific namespace
DEBUG=app:*,-app:verbose node server.js
Source Maps for Production
Source maps connect minified/bundled production code back to your original source, making production errors readable.
How Source Maps Work
Original Source Bundled/Minified Source Map
(TypeScript) (Production) (.map file)
function greet( function a(b) {
name: string {return"Hello "+b} "sources": ["greet.ts"],
) { "mappings": "AAAA,..."
return `Hello ${name}`; }
}
Error in production: With source map:
"Error at a (bundle.js:1:42)" -> "Error at greet (greet.ts:3:10)"
Generating Source Maps
// next.config.js (Next.js)
module.exports = {
productionBrowserSourceMaps: true, // include in client bundle
// Or better: upload to error tracking service
};
// vite.config.ts (Vite)
export default defineConfig({
build: {
sourcemap: true, // generates .map files
// sourcemap: 'hidden' // generates maps but doesn't reference them in bundle
},
});
// tsconfig.json
{
"compilerOptions": {
"sourceMap": true,
"inlineSources": true
}
}
Source Maps with Error Tracking
Upload source maps to your error tracking service (Sentry, Datadog, etc.) so production errors show original source -- without exposing maps to users.
# Upload source maps to Sentry during build
- name: Build
run: npm run build
- name: Upload Source Maps
run: npx @sentry/cli sourcemaps upload --release=${{ github.sha }} ./dist
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: my-org
SENTRY_PROJECT: my-app
Error Tracking and Alerting
Sentry Integration
// src/lib/sentry.ts
import * as Sentry from '@sentry/node';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
release: process.env.APP_VERSION,
tracesSampleRate: 0.1, // 10% of transactions
beforeSend(event) {
// Scrub sensitive data before sending
if (event.request?.headers) {
delete event.request.headers['authorization'];
delete event.request.headers['cookie'];
}
return event;
},
});
// Capture errors with context
try {
await processOrder(orderId);
} catch (error) {
Sentry.withScope((scope) => {
scope.setTag('operation', 'order-processing');
scope.setContext('order', { orderId, userId, amount });
Sentry.captureException(error);
});
throw error;
}
Debugging Common Issues
Memory Leaks
// Common leak: event listeners not cleaned up
useEffect(() => {
const handler = () => console.log('resize');
window.addEventListener('resize', handler);
// Missing cleanup! Handler accumulates on every re-render
// Fix: return cleanup function
return () => window.removeEventListener('resize', handler);
}, []);
// Common leak: intervals not cleared
useEffect(() => {
const interval = setInterval(fetchData, 5000);
// Missing cleanup!
// Fix:
return () => clearInterval(interval);
}, []);
# Detect memory leaks in Node.js
node --inspect --max-old-space-size=512 server.js
# In Chrome DevTools:
# Memory panel > Take heap snapshot > Compare snapshots
# Look for growing object counts between snapshots
Async Debugging
// Unhandled promise rejection -- silent failure
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json(); // what if response is not OK?
}
// Better: handle errors explicitly
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`Failed to fetch user ${id}: ${response.status}`);
}
return response.json();
}
// Catch unhandled rejections globally
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection', {
reason: reason instanceof Error ? reason.message : String(reason),
stack: reason instanceof Error ? reason.stack : undefined,
});
});
Race Conditions
// Bug: stale data from race condition
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
// If user types fast, earlier requests may resolve after later ones
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => setResults(data)); // might set stale results
}, [query]);
// Fix: use AbortController to cancel previous requests
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(res => res.json())
.then(data => setResults(data))
.catch(err => {
if (err.name !== 'AbortError') throw err;
});
return () => controller.abort(); // cancel on new query
}, [query]);
}
Debugging Checklist
When something goes wrong, follow this systematic approach:
1. REPRODUCE
[ ] Can you reproduce it consistently?
[ ] What are the exact steps?
[ ] What environment (browser, OS, Node version)?
2. ISOLATE
[ ] When did it start (which commit)?
[ ] Does it happen in all environments?
[ ] Is it specific to certain inputs?
3. INVESTIGATE
[ ] Check logs (application, server, browser console)
[ ] Check network requests (status codes, payloads)
[ ] Add targeted logging around the suspect area
[ ] Use debugger/breakpoints to inspect state
4. HYPOTHESIZE
[ ] What do you think is causing it?
[ ] What evidence supports your hypothesis?
[ ] What would disprove it?
5. FIX
[ ] Make the smallest change that fixes the bug
[ ] Write a test that reproduces the bug (fails before fix, passes after)
[ ] Verify the fix doesn't break anything else
6. PREVENT
[ ] Add monitoring/alerting for this failure mode
[ ] Update logging to make future debugging easier
[ ] Document the root cause and fix
Key Takeaways
- Use structured logging (JSON) in production -- it's searchable, filterable, and parseable
- Choose the right log level: debug for development, info for operations, error for failures
- Never log sensitive data -- passwords, tokens, card numbers, PII
- Master Chrome DevTools: Console, Sources (breakpoints), Network, Performance
- Use conditional breakpoints and logpoints instead of scattering console.log everywhere
- Source maps connect minified production code back to original source -- upload them to error tracking services
- Use AbortController to prevent race conditions in async operations
- Clean up event listeners and intervals to prevent memory leaks
- Follow a systematic debugging checklist: reproduce, isolate, investigate, hypothesize, fix, prevent
- The
debuggerstatement is your friend during development but must never reach production