---
title: "Page through results"
description: "Iterate over listings, or fetch one page at a time and resume later."
canonical_url: "https://forges.link/guides/pagination"
---
# Page through results

> Iterate over listings, or fetch one page at a time and resume later.

Forges return long lists in pages, and `forges` gives you two ways to work with them. Most listings come in two forms: `list()` returns an async iterable that fetches pages as your loop needs them, and `listPage()` returns a single page. The iterable is the easier choice in most cases, while a single page is useful when you want to store your position and carry on later.

## Iterate

When you want every item, or only the first few, use `for await`. The next page is only requested when the loop gets to the end of the current one:

```ts
let seen = 0

for await (const thread of gh.threads.list(repo, { state: 'open' })) {
  console.log(thread.title)
  if (++seen === 20) {
    break
  }
}
```

A `break` stops the requests, so you never pay for pages that you don't read. To change the size of a page, pass `perPage`, and to abort a listing from outside the loop, pass a `signal`.

## Fetch a page

A page has `items` and, usually, a `cursor`. If the forge reports them, the page also carries `rateLimit`, `total` and `warnings`:

```ts
const page = await gh.threads.listPage(repo, { perPage: 50 })

for (const thread of page.items) {
  console.log(thread.title)
}
```

To count a listing without reading every page, use `page.total`. The forge reports it only sometimes, so check for `undefined` before you rely on it. See [the data model](https://forges.link/concepts/data-model#pages-and-totals) for where it comes from.

The `cursor` is missing once there is nothing left to read. As long as it is there, you can pass it back in the options to get the next page:

```ts
if (page.cursor) {
  const next = await gh.threads.listPage(repo, {
    perPage: 50,
    cursor: page.cursor,
  })
}
```

The contents of a cursor depend on the forge, so treat it as opaque. When you save it, store the whole object, and hand it back unchanged.

Keep in mind that `perPage` is an upper limit and not a promise. A page can hold fewer items and still have a `cursor`. This happens when a forge can't filter on a field that you asked for, such as merged pull requests on Forgejo and Gitea. The provider then reads a page from the forge and drops the items that don't match. If that leaves a page empty, `listPage()` keeps reading until it finds an item, reaches the end of the listing or hits an internal limit on how many pages it reads. In that last case you get an empty page that still has a `cursor`, which is rare but possible. For this reason, keep reading for as long as `cursor` is set, rather than stopping at the first short page.

## List merged pull requests

To list pull requests that have been merged, set `state: 'merged'`. The state `'closed'` includes merged pull requests as well:

```ts
const merged = await gh.threads.listPage(repo, { state: 'merged' })
```

Merged pull requests don't exist among issues, so the listing is empty if you also set `kind: 'issue'`. On GitHub, this listing reads from the search API. The search API has a lower rate limit than the rest of GitHub's API, and it can lag a little behind the repository.

## Polling with conditional requests

A sync loop reads the same first pages over and over, and most of the time nothing on them has changed. Conditional requests make those reads cheap. When the forge sends an ETag, the first page of a listing comes with an `etag`. To read the first page again, pass the `etag` back as `cursor: { etag }`, with the same options as before. If nothing on the page changed, the page has `notModified: true` and no items, and the provider sends no further requests for it, such as GitHub's batched read of checks:

```ts
let etag: string | undefined

const page = await gh.threads.listPage(repo, {
  state: 'all',
  sort: 'updated',
  cursor: etag ? { etag } : undefined,
})

if (!page.notModified) {
  handle(page.items)
  etag = page.etag
}
```

Only the first page is conditional. A cursor with a `nextUrl` or a `token` reads a later page, and that request never sends the `etag`. A request carries `If-None-Match` only when you pass an `etag`, and if the forge responds with a full page anyway, you get a normal page with a new `etag`. With `sort: 'updated'`, and with no filter that an item can drop out of, such as `state: 'open'` or `labels`, an item that changes moves to the first page, which makes the first page a good signal for the whole listing.

The table shows the listings whose first page carries an `etag` in a recording of the forge, and which the tests read again with it to get a page with `notModified`. Forges that aren't in the table send no ETag with their listings, or build each page from many requests, as Tangled does. GitLab's ETags are weak, and it responds `304` to them all the same.

<table>
<thead>
  <tr>
    <th>
      Listing
    </th>
    
    <th>
      GitHub
    </th>
    
    <th>
      GitLab
    </th>
    
    <th>
      Bitbucket
    </th>
    
    <th>
      Gitee
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        ci.jobsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        ci.runsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        contents.branchesPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        contents.commitsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        contents.tagsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        contents.treePage
      </code>
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        installations.listPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        installations.reposPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        notifications.listPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        orgs.teamMembersPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        orgs.teamsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        releases.listPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.assignableUsersPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.byOwnerPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.collaboratorsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.contributorsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.eventsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.labelsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.listPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.milestonesPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        repos.reviewerCandidatesPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        search.codePage
      </code>
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        search.commitsPage
      </code>
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        search.reposPage
      </code>
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        search.threadsPage
      </code>
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        securityAlerts.listPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.commentsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.commitsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.eventsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.filesPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.listPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        threads.reactionsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        users.eventsPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        webhooks.deliveriesPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
    
    <td>
      ❌
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        webhooks.listPage
      </code>
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ✅
    </td>
    
    <td>
      ❌
    </td>
  </tr>
</tbody>
</table>

The activity feeds of Tangled are conditional too, with an `etag` that the provider computes; see [Activity feeds](https://forges.link/providers/tangled#activity-feeds). A page has no `etag` in these cases, so every read of it is a full read:

- The listing reads more than one endpoint, because one `etag` can't cover them all. This is the case for `threads.listPage` without `kind` on GitLab, Gitee, Azure DevOps and Tangled, which list issues and then pull requests; pass `kind` for a conditional listing. Gitee also reads two states for open or closed issues and for closed pull requests. The same goes for `notifications.listPage` with `all: true` on GitLab, `repos.listPage` on Bitbucket for an account in more than one workspace, `securityAlerts.listPage` without `kind` on GitHub, and `threads.reviewsPage` on GitHub, Forgejo and Gitea, which reads review comments as well.
- The provider read past the first page of the forge to find an item, because it drops items that the forge can't filter out. On GitHub, for example, a listing of issues alone skips the pull requests that GitHub lists among them.
- The forge sends no ETag, as GitHub does for search and for its GraphQL API, which serves discussions.

On GitHub, the validator of the notifications listing doesn't change for the account's own activity, so a conditional poll alone can miss the changes that the user made elsewhere, such as reading a notification on github.com. Read the listing without an `etag` now and then.

Also on GitHub, the `etag` of `threads.listPage` covers the issues listing, not the `checks` of its pull requests, which the provider reads separately. A page can be `notModified` while the checks of a pull request on it have changed, so read `checks` with `threads.getMany()` when you need them current.

In a browser, the forge has to allow the `If-None-Match` header and expose the `ETag` header to scripts. GitHub, GitLab and Bitbucket do both. Codeberg refuses `If-None-Match` in its CORS preflight, and Forgejo and Gitea don't expose `ETag`, so a browser can't read an `etag` from them in the first place. Since the provider sends `If-None-Match` only for an `etag` that you pass, reads from a browser still work there.

If you poll a GitHub feed or the notifications listing again sooner than GitHub allows, the page has `throttled: true` instead, and no request is sent. The `nextPollAt` of a page says when you may read it again; see [Activity feeds](https://forges.link/providers/github#activity-feeds).

If you would rather have the forge push changes to you than poll for them, see [Events](https://forges.link/concepts/events).


## Sitemap

See the full [sitemap](https://forges.link/sitemap.md) for all pages.
