Got is a human-friendly HTTP request library for Node.js that handles retries, streams, and errors out of the box. Unlike fetch or axios, Got ships with automatic retry logic, built-in cookie support, and pagination for APIs that return paginated data.
In this guide, we'll walk through setting up Got and using it for everything from basic requests to advanced scenarios like streaming large files and handling rate limits.
Why Got Beats Other HTTP Libraries
Got solves problems that other libraries ignore. Most HTTP clients make you handle retries manually, parse cookies yourself, or deal with stream errors in messy ways. Got does this automatically.
Here's what sets it apart: automatic retries on network failures (up to 2 times by default), built-in cookie jar support without parsing, RFC-compliant caching, and proper stream error handling that actually works. Companies like Segment use it to power their backend API, handling millions of requests daily.
The catch? Got is ESM-only starting from v12. No CommonJS require()
support. This trips up a lot of developers, but we'll fix that in step 1.
Step 1: Install and import Got (ESM setup)
First, install Got:
npm install got
Here's where most tutorials stop, but you'll hit an error if your project uses CommonJS. Got v12+ only supports ES modules. Here's how to handle both scenarios:
If you're using ES modules (package.json has "type": "module")
import got from 'got';
const response = await got('https://api.github.com/users/sindresorhus');
console.log(response.body);
This just works. The import
statement pulls in Got and you're ready to make requests.
If you're stuck with CommonJS
You have two options. Option 1 is using dynamic imports:
// myfile.js (CommonJS)
async function fetchData() {
const got = (await import('got')).default;
const response = await got('https://api.github.com/users/sindresorhus');
return response.body;
}
fetchData().then(console.log);
The await import()
loads Got asynchronously. You need to grab .default
because Got exports as a default export.
Option 2 is staying on Got v11 (the last CommonJS version):
npm install got@11
const got = require('got');
(async () => {
const response = await got('https://api.github.com/users/sindresorhus');
console.log(response.body);
})();
Got v11 still works fine for most cases, but you miss out on newer features. For new projects, just switch to ESM by adding "type": "module"
to your package.json.
Step 2: Make your first requests
Got supports all HTTP methods. Here's how to use them:
GET requests (the simplest case)
import got from 'got';
const response = await got('https://api.github.com/users/octocat');
console.log(response.body); // Raw HTML/JSON string
console.log(response.statusCode); // 200
The response object contains everything: body, status code, headers, timing data. But here's a trick most developers miss - Got has shorthand methods that parse responses automatically:
// Instead of this:
const response = await got('https://api.github.com/users/octocat', {
responseType: 'json'
});
console.log(response.body);
// Do this:
const data = await got('https://api.github.com/users/octocat').json();
console.log(data); // Already parsed JSON
The .json()
method parses the response and returns just the data. No need to access .body
. It also works with .text()
and .buffer()
.
POST requests with JSON
const data = await got.post('https://httpbin.org/post', {
json: {
username: 'testuser',
email: 'test@example.com'
}
}).json();
console.log(data);
The json
option automatically sets Content-Type: application/json
and stringifies your object. No manual JSON.stringify()
needed.
Custom headers and query params
const data = await got('https://api.github.com/search/repositories', {
searchParams: {
q: 'got',
sort: 'stars',
per_page: 5
},
headers: {
'User-Agent': 'my-app',
'Authorization': 'token YOUR_TOKEN'
}
}).json();
console.log(data.items);
searchParams
builds the query string for you. No need to manually encode URLs or concatenate strings. It handles arrays and nested objects too.
Here's something most tutorials don't mention - you can pass a URL object instead of a string:
const url = new URL('https://api.github.com/repos/sindresorhus/got');
url.searchParams.set('per_page', '100');
const data = await got(url).json();
This is useful when you're building URLs programmatically or need to manipulate existing URLs.
Step 3: Handle errors the right way
Got throws different error types depending on what went wrong. Understanding these prevents silent failures:
import got, { HTTPError, RequestError } from 'got';
try {
const data = await got('https://api.github.com/users/nonexistentuser123456').json();
} catch (error) {
if (error instanceof HTTPError) {
// Server responded with error status (4xx, 5xx)
console.log('Status:', error.response.statusCode);
console.log('Body:', error.response.body);
console.log('Message:', error.message);
} else if (error instanceof RequestError) {
// Network error, timeout, DNS failure
console.log('Network error:', error.message);
console.log('Code:', error.code); // ETIMEDOUT, ENOTFOUND, etc.
} else {
// Something else broke
console.log('Unexpected error:', error);
}
}
HTTPError
means the server responded but with a bad status code. RequestError
means the request never completed - network issues, timeouts, or DNS problems.
Here's the key difference: with HTTPError
, you can still access error.response
to see what the server said. With RequestError
, there's no response at all.
Custom error handling with hooks
const client = got.extend({
hooks: {
beforeError: [
error => {
// Add custom context to errors
const {request, response} = error;
if (response && response.body) {
error.message = `${error.message}: ${response.body}`;
}
return error;
}
]
}
});
The beforeError
hook runs right before Got throws an error. You can modify the error message, log details, or even change what error gets thrown.
Disable automatic error throwing
Sometimes you want to handle bad status codes yourself:
const response = await got('https://httpbin.org/status/404', {
throwHttpErrors: false
});
console.log(response.statusCode); // 404
console.log(response.ok); // false
if (!response.ok) {
// Handle error your way
console.log('Request failed but we handled it gracefully');
}
Set throwHttpErrors: false
and Got won't throw on 4xx/5xx responses. Useful when you're scraping and want to log failed requests without crashing.
Step 4: Work with streams and large files
Streams are where Got really shines. Unlike axios, Got has first-class stream support.
Download large files efficiently
import got from 'got';
import {createWriteStream} from 'fs';
import {pipeline} from 'stream/promises';
await pipeline(
got.stream('https://example.com/large-file.zip'),
createWriteStream('downloaded.zip')
);
console.log('Download complete');
pipeline()
from stream/promises
is critical here. It handles backpressure and errors automatically. Never use .pipe()
for production code - it doesn't propagate errors properly.
Here's what happens without pipeline()
:
// BAD: This looks fine but has a bug
got.stream('https://example.com/file.zip')
.pipe(createWriteStream('file.zip'));
// If the download fails halfway, the error won't propagate
// Your write stream never knows the download failed
The stream keeps writing even after the download errors out. You end up with a corrupted file and no error.
Upload files with streams
import {createReadStream} from 'fs';
const response = await got.post('https://httpbin.org/post', {
body: createReadStream('upload.txt')
}).json();
console.log(response);
Got automatically sets Transfer-Encoding: chunked
when you pass a stream. No need to know the file size upfront.
Track download progress
const downloadStream = got.stream('https://example.com/file.zip');
downloadStream.on('downloadProgress', progress => {
const percent = Math.round(progress.percent * 100);
console.log(`Downloaded: ${percent}%`);
});
await pipeline(
downloadStream,
createWriteStream('file.zip')
);
The downloadProgress
event fires multiple times as data comes in. progress.percent
is a decimal (0 to 1), progress.transferred
shows bytes downloaded, and progress.total
shows total size (if the server sends Content-Length).
Handle stream errors properly
Stream error handling is tricky. Here's the right way:
import {pipeline} from 'stream/promises';
try {
await pipeline(
got.stream('https://example.com/file.zip'),
createWriteStream('file.zip')
);
console.log('Success');
} catch (error) {
console.log('Stream failed:', error.message);
// Clean up partial file
fs.unlink('file.zip', () => {});
}
pipeline()
from stream/promises
returns a promise that rejects on any error. This is way cleaner than attaching .on('error')
handlers to every stream in the chain.
Step 5: Set up retries, hooks, and pagination
Got's advanced features are where it separates from other libraries.
Automatic retries
Got retries failed requests by default. Here's how to configure it:
const data = await got('https://unreliable-api.com/data', {
retry: {
limit: 3,
methods: ['GET', 'POST'],
statusCodes: [408, 413, 429, 500, 502, 503, 504],
errorCodes: [
'ETIMEDOUT',
'ECONNRESET',
'EADDRINUSE',
'ECONNREFUSED',
'EPIPE',
'ENOTFOUND',
'ENETUNREACH',
'EAI_AGAIN'
]
}
}).json();
limit
sets max retries (default is 2). statusCodes
are HTTP status codes that trigger a retry. errorCodes
are network error codes (timeouts, DNS failures, etc.).
By default, Got only retries GET
, PUT
, HEAD
, DELETE
, and OPTIONS
. It won't retry POST
unless you explicitly add it to methods
.
Custom retry logic
const data = await got('https://api.example.com/data', {
retry: {
limit: 5,
calculateDelay: ({attemptCount, retryOptions, error, computedValue}) => {
// Exponential backoff with jitter
const baseDelay = 1000;
const maxDelay = 10000;
const delay = Math.min(
baseDelay * Math.pow(2, attemptCount),
maxDelay
);
// Add random jitter to prevent thundering herd
const jitter = Math.random() * 1000;
return delay + jitter;
}
}
}).json();
calculateDelay
gives you full control over retry timing. attemptCount
starts at 1. Return the delay in milliseconds before the next retry.
The default algorithm is: 1000 * Math.pow(2, attemptCount) + Math.random() * 100
. This creates exponential backoff: 1s, 2s, 4s, 8s, etc.
Use hooks for auth tokens
Hooks let you modify requests before they're sent:
let accessToken = 'initial_token';
const client = got.extend({
hooks: {
beforeRequest: [
options => {
options.headers.authorization = `Bearer ${accessToken}`;
}
],
afterResponse: [
(response, retryWithMergedOptions) => {
if (response.statusCode === 401) {
// Token expired, refresh it
accessToken = refreshToken();
// Retry with new token
return retryWithMergedOptions({
headers: {
authorization: `Bearer ${accessToken}`
}
});
}
return response;
}
]
}
});
// Now all requests use the token automatically
const data = await client('https://api.example.com/protected').json();
beforeRequest
runs before every request. Perfect for adding auth headers.
afterResponse
runs after getting a response. If you return retryWithMergedOptions()
, Got retries the request with new options. This is how you implement token refresh logic.
Pagination for APIs
Many APIs return paginated results. Got handles this automatically:
const pagination = got.paginate('https://api.github.com/repos/sindresorhus/got/commits', {
pagination: {
countLimit: 50,
paginate: ({response}) => {
// GitHub uses Link header for pagination
const linkHeader = response.headers.link;
if (!linkHeader) {
return false; // No more pages
}
// Parse Link header to get next page URL
const match = linkHeader.match(/<([^>]+)>; rel="next"/);
if (!match) {
return false;
}
return {url: match[1]};
}
}
});
for await (const commit of pagination) {
console.log(commit.commit.message);
}
paginate
is a function that extracts the next page URL from each response. Return false
when there are no more pages. Return an object with url
for the next page.
countLimit
stops after fetching N items total. Without it, Got keeps paginating until the API runs out of data.
By default, Got looks for a Link
header (RFC 5988). For APIs that use different pagination (like next_page
in JSON), you need a custom paginate
function:
const pagination = got.paginate('https://api.example.com/users', {
searchParams: {page: 1},
pagination: {
paginate: ({response, currentItems}) => {
const data = JSON.parse(response.body);
if (!data.next_page) {
return false;
}
return {
searchParams: {page: data.next_page}
};
}
}
});
Common mistakes to avoid
Mistake 1: Using .pipe()
instead of pipeline()
Never chain streams with .pipe()
in production:
// BAD
got.stream(url)
.pipe(createWriteStream('file.txt'));
Errors don't propagate. Use pipeline()
from stream/promises
:
// GOOD
await pipeline(
got.stream(url),
createWriteStream('file.txt')
);
Mistake 2: Not handling 404s gracefully
By default, Got throws on 404. If you're checking if a resource exists, handle it:
// Check if user exists
try {
await got(`https://api.github.com/users/${username}`);
console.log('User exists');
} catch (error) {
if (error instanceof HTTPError && error.response.statusCode === 404) {
console.log('User not found');
} else {
throw error; // Re-throw other errors
}
}
Or use throwHttpErrors: false
and check response.ok
.
Mistake 3: Setting timeouts too low
Network requests are slow. The default timeout is generous for a reason:
// Be realistic with timeouts
const data = await got('https://slow-api.com/data', {
timeout: {
request: 30000 // 30 seconds
}
}).json();
Got has granular timeouts: lookup
(DNS), connect
(TCP), secureConnect
(TLS), socket
(inactive socket), send
(upload), response
(first byte), and request
(entire request).
Most of the time, you only need request
timeout.
Mistake 4: Not reusing instances
Creating Got instances with got.extend()
is cheap. Reuse them:
// BAD: Repeating options
await got('https://api.example.com/users', {
headers: {authorization: `Bearer ${token}`}
});
await got('https://api.example.com/posts', {
headers: {authorization: `Bearer ${token}`}
});
// GOOD: Reuse instance
const client = got.extend({
prefixUrl: 'https://api.example.com',
headers: {authorization: `Bearer ${token}`}
});
await client('users').json();
await client('posts').json();
prefixUrl
is especially useful. All requests use it as a base URL.
Final thoughts
Got handles edge cases that other HTTP libraries ignore. Automatic retries save you from writing retry logic manually. Built-in stream support makes downloading large files trivial. Hooks let you implement auth token refresh without wrapper functions.
The ESM-only requirement is annoying if you're on older projects, but it's the future of Node.js. Most issues come from not understanding how to handle errors in streams or not using pipeline()
correctly.
For most API work, Got is overkill compared to fetch. But when you need retries, pagination, or streaming, Got is the only library that gets it right without extra dependencies.