How to Use Got: Making HTTP Requests in Node.js

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.

Marius Bernard

Marius Bernard

Marius Bernard is a Product Advisor, Technical SEO, & Brand Ambassador at Roundproxies. He was the lead author for the SEO chapter of the 2024 Web and a reviewer for the 2023 SEO chapter.