1. Home
  2. Blog
  3. Software Development

Software Development

JavaScript Fetch API Tutorial: GET, POST, Error Handling and Timeouts

Fetch is the built-in way to call APIs from JavaScript, but it has one surprise that catches nearly everyone. Learn to send requests, handle every kind of failure, and build a small helper you can reuse.

9 min read
JavaScript Fetch API tutorial: GET, POST, error handling and timeouts

Key takeaways

  • fetch() returns a promise for a Response, and reading the body takes a second await.
  • HTTP errors such as 404 and 500 do not reject, so always check response.ok.
  • To send JSON, use JSON.stringify and set the Content-Type header.
  • Fetch has no default timeout option, so use AbortSignal.timeout().

Most modern web apps talk to a server all the time. The JavaScript Fetch API is the standard way to do that, and it is built into browsers and into Node.js 18 and later, so there is nothing to install.

Basic use takes two lines, yet many tutorials stop there and leave out the part that causes real bugs: failure handling. We tested the examples in this guide against a small local server on Node.js 24, and the output shown comes from that run. example.com in the code stands in for your own API. If promises are new to you, begin with JavaScript promises explained.

What is the Fetch API?

Fetch is a promise-based interface for making HTTP requests. It replaced the older, callback-driven XMLHttpRequest, and many projects now use it rather than adding a library for simple calls.

Feature Fetch Axios (library)
Installation Built in Added as a dependency
JSON body Stringify it yourself Automatic
Parse JSON response Call response.json() Automatic
HTTP errors reject? No Yes, by default
Timeout Through AbortSignal A timeout option

A GET request

Fetch makes a GET request by default. There are two steps, and each one needs await: first the response headers arrive, and then you read the body.

const response = await fetch("https://example.com/users/1");
const user = await response.json();

console.log(user);

// { id: 1, name: 'Asha' }

The two steps exist because the body may be large and arrive later than the headers. In a browser script, top-level await needs a module, so you will usually put this inside an async function. See our JavaScript async/await guide.

The Response object

Before you read the body, you can inspect the response:

console.log(response.status, response.ok, response.headers.get("content-type"));

// 200 true application/json
Property or method Gives you
response.ok true for statuses 200 to 299
response.status The status code, such as 404
response.headers.get(name) One response header
response.json() The body parsed as JSON
response.text() The body as a string
response.blob() Binary data, such as an image

A body can be read only once. Calling json() a second time failed in our test:

await res.json();
await res.json();

// TypeError: Body is unusable: Body has already been read

If you do need to read it twice, call response.clone() first.

The big surprise: a 404 does not throw

This is the most important thing in this article. We requested a user who does not exist:

const res = await fetch("https://example.com/users/999");
console.log(res.status, res.ok);

// 404 false

No exception was thrown. As far as fetch is concerned, the request worked: the server was reached and it answered. The promise rejects only when no usable response arrives at all, as with a network failure. So code like this has a hidden bug:

try {
  const res = await fetch(url);
  const data = await res.json();   // may be an error payload
  showUser(data);                  // runs anyway for 404 and 500
} catch (error) {
  showError();                     // does not run for 404 or 500
}

Check our HTTP status codes cheat sheet to see what each code means.

A reusable helper

Do not repeat that check everywhere. Wrap it once:

async function getJson(url, options) {
  const response = await fetch(url, options);
  if (!response.ok) {
    throw new Error(`Request failed with status ${response.status}`);
  }
  return response.json();
}

try {
  await getJson("https://example.com/users/999");
} catch (error) {
  console.log("caught:", error.message);
}

// caught: Request failed with status 404

Now HTTP errors and network errors both end up in the same catch. This small function is worth having in every project. A real version might also read an error message from the response body, and handle a 204 No Content response, which has no JSON to parse.

POST JSON data

Pass an options object as the second argument. For JSON, you must do two things yourself: convert the data to a string and set the header.

const created = await getJson("https://example.com/users", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ name: "Ben" }),
});

console.log(created);

// { id: 2, name: 'Ben', type: 'application/json' }

Our test server echoed back the content type it received, confirming the header arrived. Forgetting that header is a common cause of confusing server errors, because many frameworks only parse JSON when it is declared. To upload a form or files, pass a FormData object as the body, and do not set Content-Type, since the browser needs to add a boundary value itself.

Other methods and options

Option Example Purpose
method "PUT", "PATCH", "DELETE" The HTTP method
headers { Authorization: "Bearer ..." } Request headers
body JSON.stringify(data) The request body
signal AbortSignal.timeout(5000) Cancelling
credentials "include" Sending cookies cross-origin
cache "no-store" Browser cache behaviour

Choosing between REST and other API styles? Our comparison of REST vs GraphQL shows how fetch fits with each.

Query strings

Do not build query strings by joining text, because special characters will break them. URLSearchParams handles the encoding for you:

const params = new URLSearchParams({ q: "red shoes", page: 2 });
console.log(params.toString());

// q=red+shoes&page=2

await getJson(`https://example.com/search?${params}`);

// { q: 'red shoes', page: '2' }

The server received red shoes correctly, with the space restored.

Timeouts and cancelling

Fetch will wait a long time on a slow server unless you tell it otherwise. AbortSignal.timeout() sets a limit. Our test endpoint took two seconds, against a limit of 200 milliseconds:

try {
  await fetch("https://example.com/slow", { signal: AbortSignal.timeout(200) });
} catch (error) {
  console.log(error.name);
}

// TimeoutError

To cancel manually, for example when a user leaves a page or types a new search term, use an AbortController:

const controller = new AbortController();
const pending = fetch("https://example.com/slow", { signal: controller.signal });

controller.abort();

try {
  await pending;
} catch (error) {
  console.log(error.name);
}

// AbortError

To run several requests at once, use Promise.all, which is much faster than awaiting them one at a time:

const [user, search] = await Promise.all([
  getJson("https://example.com/users/1"),
  getJson("https://example.com/search?q=a&page=1"),
]);

Every kind of failure

Here is what each failure looked like in our run, along with how to tell them apart:

Situation What happens How to detect it
Server returns 404 or 500 Promise fulfills response.ok is false
Server cannot be reached Rejects with TypeError catch
Timeout reached Rejects with TimeoutError error.name
Manually cancelled Rejects with AbortError error.name
Body is not valid JSON json() rejects with SyntaxError catch

That last row is common in production. A proxy returning a 502 often sends an HTML error page. We reproduced this, and res.json() threw a SyntaxError. Checking response.ok before parsing means you report the real problem rather than a confusing parse error.

Tip: Test these failure paths on purpose. Browser developer tools can simulate being offline, and test tools can mock a 500 response. Our API testing with Postman guide covers checking the API side.

CORS and credentials

In a browser, a request from one origin to another is subject to CORS, the cross-origin resource sharing rules. If you see an error mentioning Access-Control-Allow-Origin, the fix belongs on the server, which must allow your site. In your JavaScript, the failure shows up only as a generic TypeError, so look at the browser console for the actual reason. Node.js does not enforce CORS, so a request that works in a script can still fail in the browser.

By default, browsers do not send cookies on cross-origin requests. Set credentials: "include" if you need them, and make sure the server allows it. Never put secret API keys in browser JavaScript, since anyone can read them. Keep them on your server.

Common mistakes

  • Not checking response.ok. Error responses are treated as success.
  • Forgetting the second await. response.json() returns a promise too.
  • Forgetting JSON.stringify. Passing an object as the body sends [object Object].
  • Setting Content-Type with FormData. This breaks the boundary, so leave it out.
  • Having no timeout. A hung request can leave a page’s spinner running indefinitely.
  • Parsing JSON for an empty response. A 204 has no body, and json() will throw.

Final thoughts

Fetch is simple once you remember its main rule: a response is not the same as a success. Copy the small helper into your project, add a timeout and test your failure paths. As a next step, build a tiny page that loads data from an API, shows a clear error message and lets the user cancel a slow search.

Frequently asked questions

Does fetch throw an error on a 404?

No. fetch only rejects when no response is received, such as on a network failure. For HTTP errors, check response.ok or response.status yourself.

How do I send JSON with fetch?

Set method to POST, add a Content-Type header of application/json, and pass the data through JSON.stringify as the body.

Why do I need two awaits with fetch?

The first resolves once the response headers arrive. The second waits for the body to download and be parsed, which can happen later.

How do I set a timeout on fetch?

Pass signal: AbortSignal.timeout(5000) in the options. The request then rejects with a TimeoutError if it takes longer than that many milliseconds.

Is fetch available in Node.js?

Yes. Fetch is available globally in Node.js 18 and later. Older versions need a package, though CORS rules apply only in browsers.

Keep learning

Work with Stack Quora

Need help with testing, WordPress or SEO?

We help teams ship better software and grow their sites. Tell us what you are working on and we will suggest a clear next step.

Keep reading

More guides on testing, AI & modern development

Enjoyed this article? Pick a topic and jump straight into our newest tutorials.

Artificial Intelligence

LLM Structured Output Explained: Getting Reliable JSON From AI Models

LLM structured output explained with validation we ran in Pydantic: JSON mode and schemas, constrained decoding, the ways model output…

Read article →
DevOps

SSH Keys Explained: How They Work, ssh-keygen, Ed25519 and Safe Habits

SSH keys explained with keys we generated: public vs private keys, ssh-keygen, Ed25519 vs RSA, fingerprints, passphrases, ssh-agent, authorized_keys, config…

Read article →
DevOps

HTTP Caching Explained: Cache-Control, ETag and 304 Responses, With a Live Test

HTTP caching explained with a small server we ran: Cache-Control directives, max-age, no-cache vs no-store, ETag revalidation and 304 responses,…

Read article →
Test Automation

API Contract Testing Explained: Catch Breaking Changes Before They Ship

API contract testing explained with a schema check we ran: what a contract is, consumer-driven contracts, Pact, OpenAPI, which changes…

Read article →
Software Testing

Risk-Based Testing Explained: Score Risks, Build a Matrix and Test What Matters

Risk-based testing explained step by step: identifying risks, scoring likelihood and impact, building a risk matrix, mapping risk to test…

Read article →
Databases & SQL

SQL Constraints Explained: NOT NULL, UNIQUE, CHECK and FOREIGN KEY, Tested

SQL constraints explained with tests we ran: NOT NULL, UNIQUE, CHECK, DEFAULT, PRIMARY and FOREIGN KEY, the NULL loopholes, SQLite's…

Read article →
Databases & SQL

SQL Date Functions Explained: Adding, Comparing and Grouping Dates Without Bugs

SQL date functions explained with queries we ran: adding intervals, month-end surprises, date differences, the BETWEEN trap, grouping by day,…

Read article →
Software Development

Python asyncio Explained: async, await, gather and TaskGroup With Real Timings

Python asyncio explained with code we timed: coroutines, await, gather, TaskGroup, error handling, timeouts, limiting concurrency and the blocking-call mistake…

Read article →
Software Development

Debounce vs Throttle in JavaScript: How They Work, When to Use Each, Tested

Debounce vs throttle in JavaScript explained with timings we measured in Node.js: how each works, simple implementations, leading and trailing…

Read article →
Software Development

Python Context Managers Explained: The with Statement, contextlib and Real Uses

Python context managers explained with code we ran: how with works, __enter__ and __exit__, contextlib.contextmanager, suppress, ExitStack, database transactions and…

Read article →
Artificial Intelligence

LLM Temperature and Top-p Explained: How AI Models Choose Their Next Word

LLM temperature and top-p explained with a simulation we ran: how probabilities are reshaped, what top-p keeps, recommended settings by…

Read article →
DevOps

YAML Explained: Syntax, the Norway Problem and Other Traps We Tested

YAML explained with a parser test: basic syntax, lists and maps, the Norway problem, versions turning into floats, octal zip…

Read article →
DevOps

Cron Jobs Explained: Syntax, Real Schedules and the Mistakes That Break Them

Cron jobs explained with schedules we verified: the five fields, operators, real examples, the day-of-month OR day-of-week trap, environment problems,…

Read article →
Software Testing

Bug Life Cycle in Software Testing: Every Status, Who Owns It and Common Mistakes

The bug life cycle explained: every defect status from New to Closed, who owns each step, rejected, deferred and reopened…

Read article →
Test Automation

Property-Based Testing in Python: Hypothesis Finds the Bugs Your Examples Miss

Property-based testing explained with Hypothesis tests we ran: properties vs examples, strategies, shrinking, the real bugs it found in our…

Read article →
Databases & SQL

SQL Pagination Explained: OFFSET vs Keyset, With a Million-Row Test

SQL pagination explained with a million-row test we ran: LIMIT and OFFSET, why deep pages get slow, keyset (cursor) pagination,…

Read article →
Databases & SQL

SQL EXISTS vs IN: Differences, the NOT IN NULL Trap and Performance

SQL EXISTS vs IN explained with queries we ran: how each works, why NOT IN returns no rows when a…

Read article →
Software Development

Python Logging Explained: Levels, Handlers, Formatters and Mistakes to Avoid

Python logging explained with code we ran: levels, basicConfig quirks, named loggers, handlers and formatters, logger.exception, lazy formatting, duplicate logs…

Read article →
Software Development

JavaScript Map vs Object: Key Differences, Pitfalls and a Benchmark

JavaScript Map vs Object explained with code we ran in Node.js: key types, ordering, the prototype trap, JSON, size, WeakMap…

Read article →
Software Development

Python Type Hints Explained: Annotations, mypy and the Errors It Catches

Python type hints explained with a real mypy run: basic annotations, Optional and unions, TypedDict, Literal, Protocol, generics, Callable, strict…

Read article →
Artificial Intelligence

LLM Tokens Explained: How AI Models Read Text, Count Costs and Fill Context Windows

LLM tokens explained with a tokenizer we ran: how text is split, why counts differ by language and tokenizer, context…

Read article →
DevOps

Semantic Versioning Explained: MAJOR.MINOR.PATCH, Ranges and Release Rules

Semantic versioning explained with code we ran: MAJOR.MINOR.PATCH rules, pre-releases, correct version sorting, caret and tilde ranges, 0.x versions, lockfiles…

Read article →
DevOps

Git Reset vs Revert: How to Undo Commits Safely, With a Tested Example

Git reset vs revert explained with a repository we built: soft, mixed and hard reset, revert commits, reverting merges, recovering…

Read article →
Software Testing

The Test Pyramid Explained: Layers, Ratios and When to Break the Shape

The test pyramid explained: unit, integration and end-to-end layers, sensible ratios, the ice cream cone anti-pattern, the testing trophy and…

Read article →
Test Automation

pytest Fixtures Explained: Scope, yield Teardown, conftest.py and Factories

pytest fixtures explained with a suite we ran: how fixtures work, scopes, yield teardown, conftest.py, factory fixtures, parametrized fixtures, built-ins…

Read article →
Databases & SQL

SQL Upsert Explained: INSERT ON CONFLICT, MERGE and the REPLACE Trap

SQL upsert explained with queries we ran: INSERT ON CONFLICT DO UPDATE and DO NOTHING, the excluded row, conditional updates,…

Read article →
Databases & SQL

SQL LIKE Operator Explained: Wildcards, Case Sensitivity and Performance

The SQL LIKE operator explained with queries we ran: % and _ wildcards, escaping, NOT LIKE and NULL, case sensitivity…

Read article →
Artificial Intelligence

AI Hallucinations Explained: Why Language Models Make Things Up and How to Reduce It

AI hallucinations explained: what they are, why language models produce them, common types, how to reduce them with grounding and…

Read article →
Test Automation

Mocking in Python: unittest.mock, patch and the Mistakes That Make Tests Lie

Mocking in Python with unittest.mock, tested: Mock and return_value, side_effect, patching where a name is used, spec and autospec, mocking…

Read article →
Software Testing

Code Coverage Explained: Line vs Branch Coverage and What 100% Really Means

Code coverage explained with a real coverage.py run: line, branch and condition coverage, reading a report, why 100% coverage can…

Read article →
Artificial Intelligence

What Are AI Agents? How They Work, With a Simple Agent Loop You Can Run

What are AI agents? A plain guide to the agent loop, tools, memory and guardrails, with a small Python loop…

Read article →
Test Automation

TDD vs BDD: Key Differences, One Feature Built Both Ways and When to Use Each

TDD vs BDD explained with one discount feature built both ways: red-green-refactor with code we ran, Given-When-Then scenarios, tools, trade-offs…

Read article →
Software Testing

User Acceptance Testing (UAT): Process, Checklist and Real Examples

User acceptance testing explained: who runs it, how it differs from QA, a seven-step process, a UAT test case template,…

Read article →
Artificial Intelligence

What Are Embeddings? Vectors and Cosine Similarity Explained Simply

What are embeddings? A plain-language guide to vectors, cosine similarity and semantic search, with a small example we ran, common…

Read article →
Test Automation

Flaky Tests: What Causes Them and How to Fix Them for Good

Flaky tests explained with demos we ran: the main causes, how to confirm and diagnose one, proper fixes for timing,…

Read article →
Software Testing

Load Testing vs Stress Testing: Differences, Metrics and k6 Examples

Load testing vs stress testing explained: what each proves, spike and soak tests, the metrics that matter, why averages mislead,…

Read article →