Resource library

QA How-To

How to Test an infinite scroll in Cypress (2026)

Learn cypress how to test an infinite scroll with intercepts, container scrollTo, sentinels, virtualized lists, end-of-feed states, and flake-safe assertions.

22 min read | 2,363 words

TL;DR

Intercept feed pages, wait for first render, scroll the overflow element or sentinel, wait for the next alias, and assert list growth or a later item id. Stub a null cursor for end-of-feed. Do not use live data or fixed sleeps.

Key Takeaways

  • Stub page 1, page 2, and end responses with cy.intercept before scrolling.
  • Scroll the real overflow container; window scrollTo fails for inner scrollers.
  • Use retryable length or item identity assertions after waiting on network aliases.
  • Virtualized lists should assert edge item identity, not total DOM child counts.
  • Keep E2E to a few pages plus empty, error, and end-of-feed states.
  • scrollIntoView on IntersectionObserver sentinels is a valid trigger pattern.
  • Alias call counts catch duplicate next-page fetch bugs.

The durable approach to cypress how to test an infinite scroll is to control the data source, scroll or expose the next page trigger deliberately, and assert list growth with retryable queries. Infinite scroll tests fail when they fight animations, undetermined page sizes, or live backends that change cardinality between runs.

This guide shows patterns for window scrolling, container scrolling, scrollIntoView on sentinels, intercepting page APIs, asserting offsets and cursors, testing end-of-feed empty states, and avoiding anti-patterns like fixed sleeps and unbounded while loops in Cypress chains.

TL;DR

Concern Recommended approach
Deterministic items cy.intercept page 1, page 2, end page
Trigger load scrollTo('bottom') or scrollIntoView on sentinel
Assert growth should('have.length', n) with retry
Stop condition empty page, hasMore: false, or end marker
Container vs window scroll the element that actually overflows
Performance smoke few controlled pages, not thousands of rows

Never rely on production-like random feed length in CI.

1. Cypress How to Test an Infinite Scroll: Map the Implementation

Infinite scroll is not one browser API. Teams implement it with:

  1. Window scroll listeners that fetch when near document bottom.
  2. Overflow containers (div with overflow: auto) and internal scrollTop checks.
  3. IntersectionObserver on a sentinel element at the list foot.
  4. "Load more" buttons that look infinite but are explicit clicks.
  5. Virtualized lists (react-window, tanstack virtual) that recycle DOM nodes.

Your Cypress strategy must match the mechanism. Virtualization especially changes assertions: only a window of rows exists in the DOM, so have.length of all rows is the wrong model.

// inspect in a spike
cy.visit('/feed')
cy.get('[data-cy=feed-list]').then(($list) => {
  const style = getComputedStyle($list[0])
  cy.log('overflow', style.overflowY)
})

Document for your team: which element scrolls, which URL returns the next page, and what signals end of data.

2. Stub Pagination Responses Before the First Scroll

Determinism starts at the network boundary.

const page1 = {
  data: [
    { id: 'p1', title: 'Post 1' },
    { id: 'p2', title: 'Post 2' },
  ],
  nextCursor: 'cursor_2',
}

const page2 = {
  data: [
    { id: 'p3', title: 'Post 3' },
    { id: 'p4', title: 'Post 4' },
  ],
  nextCursor: null,
}

it('loads the next page when the feed is scrolled to the bottom', () => {
  cy.intercept('GET', '/api/feed*', (req) => {
    if (req.query.cursor === 'cursor_2') {
      req.reply(page2)
    } else {
      req.reply(page1)
    }
  }).as('feed')

  cy.visit('/feed')
  cy.wait('@feed')
  cy.get('[data-cy=feed-item]').should('have.length', 2)

  cy.scrollTo('bottom')
  cy.wait('@feed')
  cy.get('[data-cy=feed-item]').should('have.length', 4)
  cy.get('[data-cy=feed-end]').should('contain', 'You are all caught up')
})

Cursor or page query params should drive the fixture choice. If the app sends offset, mirror that in the intercept. For intercept fundamentals, use Cypress cy.intercept.

3. Window Scroll Versus Container Scroll

cy.scrollTo defaults to the window when called as cy.scrollTo('bottom'). Nested feeds need a subject.

// window
cy.scrollTo('bottom')

// specific container
cy.get('[data-cy=feed-scroll-container]').scrollTo('bottom')

// coordinates
cy.get('[data-cy=feed-scroll-container]').scrollTo(0, 1200)

Wrong scroll target is a classic false failure: the window moves a little, the inner list does not, and page 2 never loads.

it('scrolls the notifications drawer, not the page', () => {
  cy.visit('/home')
  cy.get('[data-cy=open-notifications]').click()
  cy.get('[data-cy=notifications-scroller]').scrollTo('bottom')
  cy.get('[data-cy=notification-row]').should('have.length', 40)
})

If the product uses a main layout scroller, identify it once in a helper:

const scrollFeedToBottom = () =>
  cy.get('[data-cy=feed-scroll-container]').scrollTo('bottom', {
    ensureScrollable: true,
  })

ensureScrollable helps catch cases where CSS prevented overflow and scrolling silently did nothing meaningful.

4. Sentinel Elements and scrollIntoView

IntersectionObserver feeds often place a sentinel at the bottom.

<div data-cy="feed-item">...</div>
<div data-cy="feed-sentinel" aria-hidden="true"></div>
cy.get('[data-cy=feed-sentinel]').scrollIntoView()
cy.wait('@feed')
cy.get('[data-cy=feed-item]').should('have.length', 4)

scrollIntoView brings the sentinel into the viewport of its scrollable ancestor according to browser behavior. Combine with intercept waits so you do not assert length before the response arrives.

If the sentinel is removed at end-of-feed, assert that:

cy.get('[data-cy=feed-sentinel]').scrollIntoView()
cy.wait('@feed')
cy.get('[data-cy=feed-sentinel]').should('not.exist')
cy.get('[data-cy=feed-end]').should('be.visible')

5. Retryable Length Assertions Instead of Loops

Cypress is not designed for arbitrary synchronous while loops that issue commands. Prefer declarative growth assertions.

// good: retryable assertion
cy.get('[data-cy=feed-item]').should('have.length', 4)

// risky: manual looping anti-pattern
// for (let i = 0; i < 10; i++) { cy.scrollTo('bottom') }

When you must load several pages in a controlled way, use a fixed, small number of steps with explicit waits:

function loadNextPage(expectedCount: number) {
  cy.get('[data-cy=feed-scroll-container]').scrollTo('bottom')
  cy.wait('@feed')
  cy.get('[data-cy=feed-item]').should('have.length', expectedCount)
}

it('walks three deterministic pages', () => {
  // intercepts configured for three pages of 10
  cy.visit('/feed')
  cy.wait('@feed')
  cy.get('[data-cy=feed-item]').should('have.length', 10)
  loadNextPage(20)
  loadNextPage(30)
})

If you need recursion, Cypress docs discuss patterns with function callbacks carefully so commands queue correctly. Keep page counts tiny in E2E. Exhaustive pagination belongs in API tests.

6. Cypress How to Test an Infinite Scroll With Virtualized Lists

Virtualized lists only mount visible rows plus overscan. Asserting total DOM children equal to total records will fail even when the feature works.

Better strategies:

  1. Assert the first page visible content.
  2. Scroll and assert a later item exists (contains or data-id).
  3. Assert the request sequence for next pages.
  4. Assert sticky headers or scroll restoration if relevant.
it('requests page 2 and shows a later invoice', () => {
  cy.intercept('GET', '/api/invoices*').as('invoices')
  cy.visit('/invoices')
  cy.wait('@invoices')
  cy.get('[data-cy=invoice-row][data-id=inv_1]').should('be.visible')

  cy.get('[data-cy=invoice-scroller]').scrollTo('bottom')
  cy.wait('@invoices')
  cy.get('[data-cy=invoice-row][data-id=inv_25]').should('exist')
})

Do not use have.length as total inventory for virtualized grids. Use identity of edge items and network pagination proofs.

7. Loading Indicators, Debouncing, and Duplicate Fetches

Good infinite scroll UX shows a loading row and avoids duplicate concurrent fetches. Tests can lock these contracts.

it('shows a loading marker while the next page is in flight', () => {
  cy.intercept('GET', '/api/feed*', (req) => {
    if (req.query.cursor) {
      req.reply({
        delay: 500,
        body: page2,
      })
    } else {
      req.reply(page1)
    }
  }).as('feed')

  cy.visit('/feed')
  cy.wait('@feed')
  cy.scrollTo('bottom')
  cy.get('[data-cy=feed-loading]').should('be.visible')
  cy.wait('@feed')
  cy.get('[data-cy=feed-loading]').should('not.exist')
})

Duplicate fetch regression:

cy.scrollTo('bottom')
cy.wait('@feed')
cy.get('@feed.all').should('have.length', 2) // initial + one page

If a bug fires five identical page-2 requests, the alias collection length exposes it. Combine with cy.wait with alias guidance.

8. End of Feed, Empty Feed, and Error Recovery

Happy-path growth is not enough.

it('shows empty state when the first page has no items', () => {
  cy.intercept('GET', '/api/feed*', { data: [], nextCursor: null }).as('feed')
  cy.visit('/feed')
  cy.wait('@feed')
  cy.get('[data-cy=feed-empty]').should(
    'contain',
    'No posts yet',
  )
})
it('allows retry after a next-page failure', () => {
  cy.intercept('GET', '/api/feed*', (req) => {
    if (req.query.cursor === 'cursor_2') {
      req.reply({ statusCode: 500, body: { message: 'fail' } })
    } else {
      req.reply(page1)
    }
  }).as('feed')

  cy.visit('/feed')
  cy.wait('@feed')
  cy.scrollTo('bottom')
  cy.wait('@feed')
  cy.get('[data-cy=feed-error]').should('be.visible')
  cy.get('[data-cy=feed-retry]').click()
})

For end-of-feed, assert both the final length and the end marker so a silent stop without messaging is caught if UX requires messaging.

9. Scroll Restoration and Back Navigation

Users open an item, go back, and expect position or page memory. Full restoration may be product-specific, but a focused test prevents severe jumps to the top without data.

it('keeps loaded items when returning from a detail page', () => {
  cy.intercept('GET', '/api/feed*', (req) => {
    /* pages as needed */
  }).as('feed')

  cy.visit('/feed')
  cy.wait('@feed')
  cy.scrollTo('bottom')
  cy.wait('@feed')
  cy.get('[data-cy=feed-item]').should('have.length', 4)

  cy.get('[data-cy=feed-item]').contains('Post 3').click()
  cy.location('pathname').should('include', '/posts/')
  cy.go('back')

  cy.get('[data-cy=feed-item]').should('have.length.at.least', 4)
})

Adjust expectations if the app intentionally refetches page 1 on return. The test documents the contract either way.

10. Performance and Scope Boundaries for E2E

Infinite scroll can tempt engineers to load hundreds of pages in the browser. That makes E2E slow and noisy. Keep browser tests to:

  • first page render
  • one or two additional pages
  • end-of-feed
  • one error path
  • maybe one virtualization identity check

Push deep pagination math, cursor correctness, and authorization on page 50 to API tests. Browser tests prove wiring and UX states.

Also avoid screenshotting every page of a long feed in CI. One failure screenshot at the point of assertion failure is enough; see cypress how to take a screenshot on failure for artifact strategy (link may match your library slug once published).

11. Debugging Scroll Tests That Never Load Page Two

Checklist:

  1. Confirm intercept route matches the real URL and query.
  2. Confirm you scroll the overflow container, not only the window.
  3. Confirm the app uses pixel threshold vs sentinel; adjust trigger.
  4. Check whether overflow: hidden on a parent prevents scrolling.
  5. Check whether a sticky footer covers the sentinel so intersection never fires.
  6. Log request count with cy.get('@feed.all').
  7. Temporary cy.screenshot after scroll to see position.
  8. Verify prefetch logic did not already consume the next cursor on initial load.
cy.scrollTo('bottom')
cy.get('@feed.all').should('have.length.greaterThan', 1)

If length stays 1, the UI never requested page 2. If length grows but DOM does not, you may be looking at a render or key bug, or virtualization without a stable identity assertion.

For general flake control, review Cypress retry ability.

12. Reference Pattern: Full Spec Skeleton

describe('feed infinite scroll', () => {
  beforeEach(() => {
    cy.intercept('GET', '/api/feed*', (req) => {
      const cursor = req.query.cursor
      if (!cursor) {
        req.reply({
          data: [{ id: '1', title: 'One' }, { id: '2', title: 'Two' }],
          nextCursor: 'c2',
        })
      } else if (cursor === 'c2') {
        req.reply({
          data: [{ id: '3', title: 'Three' }],
          nextCursor: null,
        })
      } else {
        req.reply({ data: [], nextCursor: null })
      }
    }).as('feed')
  })

  it('appends page two and then shows the end state', () => {
    cy.visit('/feed')
    cy.wait('@feed')
    cy.get('[data-cy=feed-item]').should('have.length', 2)

    cy.get('[data-cy=feed-scroll-container]').scrollTo('bottom')
    cy.wait('@feed')
    cy.get('[data-cy=feed-item]').should('have.length', 3)
    cy.get('[data-cy=feed-end]').should('be.visible')
  })
})

Copy this structure and replace selectors and payload shapes with your API contract.

13. GraphQL Cursors, Relay Pages, and Filter Interactions

Many modern feeds use GraphQL with cursor connections rather than REST page numbers. Intercepts still work if you match the operation name or body.

cy.intercept('POST', '/graphql', (req) => {
  const body = req.body
  if (body.operationName === 'FeedQuery') {
    const after = body.variables?.after
    if (!after) {
      req.reply({
        data: {
          feed: {
            edges: [
              { cursor: 'c1', node: { id: '1', title: 'One' } },
              { cursor: 'c2', node: { id: '2', title: 'Two' } },
            ],
            pageInfo: { endCursor: 'c2', hasNextPage: true },
          },
        },
      })
    } else {
      req.reply({
        data: {
          feed: {
            edges: [
              { cursor: 'c3', node: { id: '3', title: 'Three' } },
            ],
            pageInfo: { endCursor: 'c3', hasNextPage: false },
          },
        },
      })
    }
  }
}).as('graphql')

When filters change, apps should reset the cursor and list. Infinite scroll tests must cover that reset or they will miss a serious state bug.

it('resets the feed when the tag filter changes', () => {
  cy.visit('/feed')
  cy.wait('@graphql')
  cy.scrollTo('bottom')
  cy.wait('@graphql')
  cy.get('[data-cy=feed-item]').should('have.length', 3)

  cy.get('[data-cy=tag-filter]').select('release-notes')
  cy.wait('@graphql')
  cy.get('[data-cy=feed-item]').should('have.length', 2)
  cy.get('[data-cy=feed-item]').first().should('contain', 'One')
})

If the filter leaves residual page-2 items on screen, users see mixed result sets. E2E is an excellent place to catch that class of bug.

14. Bidirectional Infinite Scroll and Jump-to-Date

Chat products sometimes load older messages upward and newer messages downward. Bidirectional scroll needs separate stubs and careful anchor assertions.

it('loads older messages when scrolling up', () => {
  cy.intercept('GET', '/api/messages*', (req) => {
    /* branch on before/after cursors */
  }).as('messages')

  cy.visit('/channels/qa')
  cy.wait('@messages')
  cy.get('[data-cy=message-scroller]').scrollTo('top')
  cy.wait('@messages')
  cy.get('[data-cy=message][data-id=m_old_1]').should('exist')
})

Jump-to-date or jump-to-unread may replace the list rather than append. Assert replacement with identity checks: previous anchors should disappear if the product jumps, or remain if it only scrolls. Read the product contract before writing the expectation.

Memory and DOM Growth Checks (Lightweight)

E2E cannot be a full memory profiler, but you can assert that virtualization keeps DOM node counts bounded:

cy.get('[data-cy=feed-item]').its('length').should('be.lessThan', 40)
cy.scrollTo('bottom')
cy.wait('@feed')
cy.get('[data-cy=feed-item]').its('length').should('be.lessThan', 40)

If a supposed virtual list grows without bound, you found a regression. Keep thresholds illustrative and aligned to the known overscan settings, not arbitrary huge numbers.

15. Accessibility Semantics for Infinite Lists

Infinite scroll impacts accessibility: focus management, status announcements, and keyboard reachability of newly loaded items.

Practical E2E checks:

  1. After loading more, a role="status" may announce "Loaded 20 more items" if the product provides it.
  2. Tab order should still reach actionable items without keyboard traps.
  3. The sentinel should be aria-hidden if it is purely mechanical.
cy.scrollTo('bottom')
cy.wait('@feed')
cy.get('[data-cy=feed-status]').should('contain', 'Loaded')

Do not claim full WCAG conformance from these checks alone. They catch regressions in announced loading states that matter to real users.

16. Coordinating Parallel Specs and Shared Backends

If two specs hit the same live feed user, item counts race. Prefer per-test intercepts (no live dependency) or unique seeded users. When intercepts are partial and some calls leak to the network, tests flake under parallel CI.

// force all feed traffic through intercepts
cy.intercept('GET', '/api/feed*', { fixture: 'feed-page1.json' }).as('feed')

For write-side infinite lists (notifications marked read while scrolling), stub both reads and writes. Unstubbed writes in parallel runs create cross-talk that is painful to diagnose from a single failure screenshot.

Document in the README that infinite scroll specs must not rely on shared chronological data stores without namespaces. This process control is as important as the Cypress commands themselves.

17. Observability Hooks and Performance Budgets (Smoke Only)

Some teams attach performance marks when feeds append pages. Cypress can read simple marks if the app exposes them on window, but treat this as optional smoke, not a full Lighthouse replacement.

cy.window().then((win) => {
  const marks = win.performance.getEntriesByType('mark')
    .map((m) => m.name)
  expect(marks).to.include('feed:page2:rendered')
})

Only assert marks that the application intentionally creates for testing or RUM. Do not scrape internal framework marks that change between releases.

For performance budgets, prefer CI tools designed for metrics. Infinite scroll E2E should stay focused on correctness of append, reset, and end states. If a page-2 render regularly exceeds a timeout, fix the app or the fixture size rather than silently raising timeouts every sprint.

18. Storybook and Component-Level Feed Windows

If your design system includes a Feed or VirtualList component, Cypress component testing or Storybook interaction tests can verify scroll callbacks with mocked items faster than full E2E.

E2E still owns:

  • real routing to the feed page
  • auth headers on page requests
  • integration with filters and detail navigation
  • production-like layout scroll containers

Component tests own:

  • that reaching the sentinel calls onLoadMore
  • that hasMore=false hides the sentinel
  • that duplicate in-flight loads are ignored

Splitting layers prevents the browser suite from becoming the only place every list edge case is proven. When onboarding new engineers, show both layers so they do not copy a 200-line E2E for a pure component bug.

Final Practical Drill

Take a failing infinite scroll spec and rewrite it in one hour using this template: three intercept branches, one container scroll, two length assertions, one end marker. Delete sleeps. Run it twenty times in CI preview. If it still flakes, the bug is likely in app fetch locking or scroll CSS, not in Cypress syntax. That is the operational heart of cypress how to test an infinite scroll in mature teams.

Production Readiness Notes for Infinite Scroll Suites

Before marking feed automation done, confirm that local and CI base URLs serve the same scroll container structure. It is common for marketing wrappers to appear only in production-like deploys and change which element owns overflow. Run the spec once against the preview environment after merge, not only against localhost.

Also verify that feature flags for "new feed" do not leave half the suite on the legacy pager button and half on infinite scroll. Gate specs with the same flag the UI uses, or split files clearly (feed.infinite.cy.ts versus feed.pager.cy.ts). Mixed assumptions create failures that look like scroll bugs but are actually flag mismatches.

When stakeholders ask for proof, export one CI run artifact showing page-1 length, page-2 length, and the end marker visible in a screenshot. That single narrative convinces non-engineers faster than a green check alone. Keep the proof scripted so anyone can re-run it during release week without tribal knowledge.

Finally, schedule a quarterly prune: delete scroll tests that only duplicate API cursor checks, and keep the browser proofs that still protect UX wiring. Suites grow quietly; infinite scroll specs are frequent offenders because they are easy to copy and hard to love.

Interview Questions and Answers

Q: How do you test infinite scroll in Cypress?

I stub page responses with cy.intercept, wait for the first page, scroll the correct container or sentinel, wait for the next request, and assert the list grew or that a later item exists. I also assert end-of-feed behavior.

Q: Why is cy.scrollTo('bottom') not loading more items?

Often the scrollable element is an inner container, not the window. I identify the overflow element and call scrollTo on that subject. Sticky footers and non-scrollable CSS are other common causes.

Q: How do virtualized lists change assertions?

The DOM only holds visible rows, so total length assertions are misleading. I assert specific item identities after scrolling and verify pagination requests instead of counting all records in the DOM.

Q: How do you keep infinite scroll tests from flaking?

Deterministic fixtures, alias waits, retryable length or existence assertions, correct scroll targets, and small page counts. I avoid fixed sleeps and live backend cardinality.

Q: Should E2E load all pages until the end always?

No. I cover a couple of pages plus the terminal state. Full cursor correctness belongs in API tests.

Q: How do you detect duplicate page fetches?

I alias the list endpoint and assert on @alias.all length after a single scroll trigger, ensuring only the expected number of calls occurred.

Q: How do you test an IntersectionObserver sentinel?

I scrollIntoView on the sentinel (or scroll its container to the bottom), wait for the network alias, and assert list growth or sentinel removal at end-of-feed.

Common Mistakes

  • Scrolling the window when the list container owns overflow.
  • Using production data with unpredictable page sizes.
  • Asserting virtualized total length equal to record count.
  • Fixed cy.wait(2000) instead of intercept aliases.
  • Unbounded loops trying to scroll until "maybe done".
  • Ignoring error and empty states.
  • Forgetting that initial render already prefetched page 2.
  • Not waiting for the first page before scrolling.
  • Writing only click-Load-more tests when production uses pure scroll.
  • Capturing huge feeds that timeout CI without adding signal.

Conclusion

For cypress how to test an infinite scroll, control pagination with intercepts, scroll the real overflow target or sentinel, and assert growth with retryable commands. Keep E2E scope small: a few pages, end state, and failure recovery. Leave deep pagination edge cases to API tests.

Next step: rewrite one feed test to stub two pages and a null cursor, scroll the actual scroller element, and replace any fixed sleep with cy.wait('@feed') plus a length assertion.

Interview Questions and Answers

How do you automate infinite scroll with Cypress?

I stub deterministic pages, wait for the initial fetch, scroll the correct container or sentinel, wait for the next fetch, and assert growth or item identity. I also cover end-of-feed and error recovery.

Window versus container scrolling: how do you decide?

I inspect which element has overflow and actually moves content. Nested drawers and boards almost always need subject.scrollTo on the scroller, not cy.scrollTo on the window.

How do virtualized lists change your assertions?

Only visible rows exist in the DOM, so I assert that a later record appears and that page requests happened, rather than counting all rows as children.

What makes infinite scroll tests flaky?

Live backend sizes, wrong scroll targets, missing intercept waits, fixed sleeps, and sticky UI covering sentinels. Deterministic fixtures and retryable assertions remove most flakes.

How do you test a loading indicator during page fetch?

I delay the next-page intercept, scroll, assert the loading marker is visible, wait for the alias, then assert the marker is gone and items increased.

Where should deep pagination edge cases be tested?

In API tests for cursors, auth, and empty pages. E2E proves UX wiring for a few pages, not hundreds of iterations.

How do you validate scroll restoration after opening a detail page?

I load multiple pages, open an item, go back, and assert the list still has the loaded count or restored position according to the product contract.

Frequently Asked Questions

How do I trigger infinite scroll in Cypress?

Call cy.scrollTo('bottom') for window scrollers or element.scrollTo for overflow containers. For IntersectionObserver feeds, scrollIntoView on the bottom sentinel. Always pair the trigger with intercept waits.

Why does scrollTo bottom not load more data?

The list may scroll inside an inner container, CSS may prevent overflow, or a sticky footer may block the sentinel. Confirm the scroller element and the network request in DevTools.

How do I test virtualized infinite lists?

Do not assert total DOM length equals total records. Assert specific item ids appear after scrolling and verify pagination requests fired with the expected cursor or page.

How many pages should an E2E infinite scroll test load?

Usually one or two additional pages plus the terminal state. Exhaustive pagination belongs in API tests so browser suites stay fast.

How do I assert the end of the feed?

Stub a final response with empty data or nextCursor null, trigger the last load, and assert an end marker and that further scrolls do not keep fetching.

Can I write a while loop until the list stops growing?

Avoid unbounded loops in Cypress command chains. Use a fixed small number of deterministic pages with explicit expected counts instead.

How do I catch duplicate page fetches?

Alias the list endpoint and assert the length of @alias.all after a single scroll trigger matches the expected call count.

Related Guides