try/catch/finally

Learn how to use try, catch, and finally to handle errors gracefully, how errors propagate up the call stack, and how to handle errors in asynchronous code.

Detecting that something went wrong is only half the battle — a robust program also needs a way to respond to that failure without crashing entirely. JavaScript's try/catch/finally statement lets you attempt risky code, recover from failures, and guarantee that cleanup code runs no matter what happens.

throw — creating an error deliberately

The throw statement lets you deliberately signal that something has gone wrong, at any point in your own code. You can throw any value technically, but by convention you should always throw an instance of Error (or a subclass), since Error objects carry a helpful .message and stack trace.

Once a throw statement executes, JavaScript immediately stops running the current function and starts unwinding — searching up through the call stack for the nearest enclosing catch block that can handle it. If none is found, the program (or that particular execution context) terminates with an unhandled error.

try and catch

A try block wraps code that might fail. If any statement inside the try block throws an error (whether from your own throw or from a built-in operation like calling undefined), execution immediately jumps to the matching catch block, skipping the rest of the try block entirely.

The catch block receives the thrown value as a parameter (conventionally named error or err), letting you inspect error.message and error.name, log it, show a user-friendly message, or attempt a fallback. If the try block completes with no error, the catch block is skipped entirely.

finally

An optional finally block runs after the try/catch, no matter what happened — whether the try block succeeded, an error was caught, or even if the catch block itself re-threw a new error. It is the right place for cleanup code that absolutely must run, like closing a file, hiding a loading spinner, or releasing a resource.

finally even runs if the try or catch block contains a return statement — the function's return is effectively paused until finally finishes, though a return inside finally itself would override the earlier one (a pattern that's generally best avoided since it's confusing).

Error propagation

When an error is thrown inside a function that has no try/catch of its own, it doesn't just disappear — it propagates up to whichever function called it. If that caller also lacks a matching try/catch, it keeps propagating further up the call stack, function by function, until either some enclosing try/catch handles it, or it reaches the top of the program and becomes an unhandled error.

This means you don't need to wrap every single function in try/catch — often it makes more sense to let errors bubble up to one central place (like the top-level function that started an operation) where they can be handled or logged consistently.

Async error handling

Inside an async function, try/catch works naturally with await: if an awaited Promise rejects, it behaves as if that line threw an error, and the surrounding catch block catches it — this is one of the biggest advantages of async/await over raw .then()/.catch() chains.

For Promise chains without async/await, .catch() serves the same role as catch: any rejection anywhere earlier in the chain (including one thrown inside a .then() callback) is caught by the nearest .catch() further down the chain. Mixing try/catch with .then()-based code incorrectly — forgetting that a rejected Promise inside a non-awaited .then() is NOT caught by an outer synchronous try/catch — is a very common source of silently unhandled rejections.

try / catch / finally structure

catch(error) receives whatever value was thrown, typically an Error instance. finally runs unconditionally — after success, after a caught error, and even after a return inside try or catch. The catch parameter can be omitted entirely (catch { ... }) in modern JavaScript when you don't need the error value.

try {
  const result = riskyOperation();
  console.log('Success:', result);
} catch (error) {
  console.error('Something went wrong:', error.message);
} finally {
  console.log('This always runs, error or not.');
}

Code Examples

The first call successfully parses '25' into the number 25, so the try block completes, the catch block is skipped, and finally still runs. The second call parses a valid JSON string that happens to be text, triggering the manual throw for the wrong type; the catch block handles it and returns null, and finally runs there too. Expected result: Parsed age: 25 Parsing attempt finished. Failed to parse age: Age must be a number Parsing attempt finished.

} finally { console.log('Parsing attempt finished.'); } divide() throws a RangeError when dividing by zero, but has no try/catch of its own. calculateAverage() calls divide() but also doesn't catch anything — so when it's called with an empty array (dividing by 0), the error propagates up through calculateAverage() and is only finally caught by the outer try/catch that wraps both calls. Expected result: 2 Caught in outer scope: RangeError - Cannot divide by zero return divide(sum, numbers.length); // error propagates up if it throws

This shows the idiomatic pattern for handling async errors: an await inside a try block behaves just like a synchronous throw if the Promise rejects, so the same catch block that handles synchronous errors also handles both network failures and the manually thrown Error for bad status codes. The finally block guarantees the loading spinner is always hidden, regardless of whether the fetch succeeded, failed, or threw partway through. Expected result: Profile loaded: Ada Lovelace } finally { hideLoadingSpinner(); }

Common mistake: Placing hideLoadingSpinner() only at the end of the try block (instead of in finally) means the spinner stays stuck on screen forever if an error is thrown, since that line would never be reached.

function parseAge(input) {
  try {
    const age = JSON.parse(input);
    if (typeof age !== 'number') {
      throw new TypeError('Age must be a number');
    }
    console.log('Parsed age:', age);
    return age;
  } catch (error) {
    console.error('Failed to parse age:', error.message);
    return null;
  } finally {
    console.log('Parsing attempt finished.');
  }
}

parseAge('25');
parseAge('"not a number"');
function divide(a, b) {
  if (b === 0) {
    throw new RangeError('Cannot divide by zero');
  }
  return a / b;
}

function calculateAverage(numbers) {
  const sum = numbers.reduce((a, b) => a + b, 0);
  return divide(sum, numbers.length); // no try/catch here — error propagates up
}

try {
  console.log(calculateAverage([1, 2, 3]));
  console.log(calculateAverage([]));
} catch (error) {
  console.error('Caught in outer scope:', error.name, '-', error.message);
}
async function loadUserProfile(userId) {
  showLoadingSpinner();
  try {
    const response = await fetch(`https://api.example.com/users/${userId}`);
    if (!response.ok) {
      throw new Error(`Server returned ${response.status}`);
    }
    const profile = await response.json();
    console.log('Profile loaded:', profile.name);
    return profile;
  } catch (error) {
    console.error('Could not load profile:', error.message);
    showErrorMessage('Unable to load profile. Please try again.');
    return null;
  } finally {
    hideLoadingSpinner(); // guaranteed to run whether it succeeded or failed
  }
}

Error propagation up the call stack

An uncaught error keeps bubbling up through each calling function until it reaches a try/catch that handles it, or reaches the top of the program unhandled.

main()
  |
  calls calculateAverage()
              |
              calls divide()
                       |
                       throws RangeError
                       |
              (no catch here) -- propagates up
              |
  (no catch here) -- propagates up
  |
catch (error) { ... }  <-- finally handled here

Important Rules

throw immediately stops the current function and searches up the call stack for a matching catch block.

catch only runs if the try block throws; if try succeeds, catch is skipped entirely.

finally always runs — after success, after a caught error, and even around a return statement — making it ideal for cleanup.

An uncaught error propagates up through each calling function until some try/catch handles it or the program terminates.

Inside an async function, await-ing a rejected Promise behaves like a thrown error and is caught by a surrounding try/catch.

Where this shows up

Every production application relies on try/catch/finally to keep a single failed API call, a malformed piece of user input, or an unexpected null value from crashing the entire page. On EngiLipi, the online code compiler wraps each learner's submitted code execution in try/catch so a runtime error in a student's practice code shows a friendly error message instead of breaking the whole practice tool.

async function load() { fetchData().then(data => { throw new Error('bad'); }); } // wrapped in try/catch outside async function load() { try { const data = await fetchData(); if (isInvalid(data)) throw new Error('bad'); } catch (e) {... } }

A rejection or throw inside a non-awaited .then() callback happens asynchronously, after the surrounding synchronous try/catch has already finished executing — so that outer try/catch never sees it, and the rejection goes unhandled unless the chain has its own .catch().

try { riskyStuff(); } catch (e) {} // empty catch, error silently swallowed try { riskyStuff(); } catch (e) { console.error(e); /* handle or re-throw appropriately */ }

An empty catch block silently discards the error entirely, making bugs invisible and debugging far harder — at minimum, log the error, and only suppress it deliberately when you have a specific, documented reason to ignore that failure.

[Multiple Choice] If a try block contains a return statement and there is also a finally block, what happens? A. finally is skipped entirely since the function already returned B. finally still runs before the function actually returns C. It throws a SyntaxError D. The return statement is ignored

B. finally still runs before the function actually returns — finally always runs, even when try (or catch) contains a return statement — the function's return is effectively held until the finally block finishes executing.

[True / False] An error thrown inside a function with no try/catch will automatically be caught by a try/catch in the function that called it. A. True B. False

A. True — True. Uncaught errors propagate up the call stack from function to function until they reach a try/catch that handles them (or reach the top of the program unhandled).

Write an async function safeFetchJson(url) that fetches url, throws an Error if the response is not ok, and returns the parsed JSON on success. Wrap the call in try/catch/finally so that: on success it logs 'Data loaded', on failure it logs the error message and returns null, and finally always logs 'Request finished'.

async function safeFetchJson(url) { try { const response = await fetch(url); if (!response.ok) { throw new Error(Request failed: ${response.status}); } const data = await response.json(); console.log('Data loaded'); return data; } catch (error) { console.error(error.message); return null; } finally { console.log('Request finished'); } }

Put the fetch, ok check, and response.json() all inside the try block so any of their failures are caught by the same catch.