Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sluggish scroll on chromium based browsers when using many columns #860

Open
2 tasks done
DaanRoet opened this issue Oct 21, 2024 · 37 comments
Open
2 tasks done

Sluggish scroll on chromium based browsers when using many columns #860

DaanRoet opened this issue Oct 21, 2024 · 37 comments

Comments

@DaanRoet
Copy link

Describe the bug

Trying to render a Tanstack table with around 50 columns and I want to avoid using column virtualization and just stick with row virtualization. Seems like in Edge/Chrome I am getting pretty low FPS (~20 fps or so) once I start adding more and more columns. Weird thing is that this is even the case when I use a small dataset (200 rows) and overscan all of the items. On Firefox however I am getting good performance (~60fps).

Also I noticed that if I convert my code to useWindowVirtualizer() the scrolling performs way better, but unfortunately that doesn't suit my use case.

Your minimal, reproducible example

https://stackblitz.com/edit/tanstack-virtual-2gupur?file=src%2Fmain.tsx

Steps to reproduce

  1. add 20+ columns to the table (I did already in the StackBlitz
  2. Start scrolling in a chromium based browser

Expected behavior

  1. 60fps-ish scrolling

How often does this bug happen?

Every time

Screenshots or Videos

No response

Platform

Windows 11, Edge v130

tanstack-virtual version

v3.10.8

TypeScript version

No response

Additional context

No response

Terms & Code of Conduct

  • I agree to follow this project's Code of Conduct
  • I understand that if my bug cannot be reliable reproduced in a debuggable environment, it will probably not be fixed and this issue may even be closed.
@piecyk
Copy link
Collaborator

piecyk commented Oct 23, 2024

Thanks for reporting this! I’ll check into the issue soon and see what can be done to improve the performance. Appreciate your patience!

@oshcherbakovv
Copy link

I'm facing the same problem

@MatchuPitchu
Copy link

MatchuPitchu commented Nov 1, 2024

In my use case, I could solve the scroll lag when I added the following to the table container:

.container {
  // ... 
  contain: 'paint',
  will-change: 'transform',
}

@DaanRoet
Copy link
Author

DaanRoet commented Nov 2, 2024

In my use case, I could solve the scroll lag when I added the following to the table container:

.container {
// ...
contain: 'paint',
will-change: 'transform',
}

Setting the will-change: transform works for me as well, thank you for sharing!

@MatchuPitchu
Copy link

@piecyk It would be great if the Tanstack team could figure out why the CSS property is necessary? For me, the lagging only occurred after I revised and refactored our table components. So while it worked smoothly before, I now need this CSS property. That's strange.

@piecyk
Copy link
Collaborator

piecyk commented Nov 3, 2024

@MatchuPitchu That's interesting! It seems like something might have changed in Chrome recently. When I tested will-change: transform before, I didn’t notice much difference, but I’ll look into it again. Also, another CSS property that can help with virtualization issues is overflow-anchor: none; it can sometimes prevent unexpected scroll. I’ll experiment with these and let you know if I find anything useful!

@MatchuPitchu
Copy link

MatchuPitchu commented Nov 3, 2024

Thank you. During the test I noticed that the lagging in Edge was much less, even without the CSS property. This confirms that it could have something to do with Chrome.

Do you mean or on the items?

.container {
  overflow-anchor: none;
}

@piecyk
Copy link
Collaborator

piecyk commented Nov 4, 2024

@MatchuPitchu directly on each item.

@soliyapintu
Copy link

@piecyk, I’m still experiencing sluggish scrolling when using the scrollbar. However, when I scroll using the Shift key combined with the arrow keys, the issue is significantly less noticeable.

@vansh3476
Copy link

@soliyapintu facing same issue. @piecyk any solution?

@wwesolowski
Copy link

@piecyk my issues still persists, I have like 20 FPS on scrolling and elements load with delay

@piecyk
Copy link
Collaborator

piecyk commented Nov 25, 2024

@soliyapintu @vansh3476 @wwesolowski just to be on same page, basic combining react-table with react-virtual everyone experiencing these problems?

@vansh3476
Copy link

@piecyk Unfortunately, yes. The scrolling behavior is smooth in Firefox and Edge, but it is sluggish in Chrome. Additionally, I have sticky columns on both the left and right sides. Does react-virtual provide any functionality to handle sticky columns?

@soliyapintu
Copy link

@piecyk Yes facing same problem.

@wwesolowski
Copy link

@piecyk tanstack table + tanstack virtual

@soliyapintu
Copy link

In my case, maybeNotify takes more than 1300 ms
Image

@ihryshchenko
Copy link

In my case, maybeNotify takes more than 1300 ms Image

+1 Same problem

@xHeaven
Copy link

xHeaven commented Dec 1, 2024

+1, extremely laggy scrolling on Chromium-based browsers, but it seems like there is no issue at all on Firefox-based ones.

I can also confirm that putting the contain-paint and will-change-transform Tailwind classes on my table container somewhat fixes the issue — though I feel like Firefox somehow still has superior performance, Chrome is still "lagging" behind with render performance, however, the scrolling performance is MUCH better.

@noah-franklin
Copy link

noah-franklin commented Dec 4, 2024

thank you the
style={{ contain: 'paint', willChange: 'transform'}}
did the trick

@YuesIt17
Copy link

Hi!
I have this problem too, but I made scrolling a little faster by using smooth scrolling,
which is described in the TanStack documentation

@vansh3476
Copy link

After extensive debugging and analysis, I observed that rendering simple text resolves the lagging issue.

@soliyapintu
Copy link

soliyapintu commented Jan 9, 2025

I significantly improved our table's performance by wrapping the virtualized absolute div children with React.memo. This optimization reduces unnecessary re-renders, enhancing efficiency.

Before memoization :- #860 (comment)
After memoization :-

Image

@YuesIt17
Copy link

YuesIt17 commented Jan 9, 2025

I significantly improved our table's performance by wrapping the virtualized absolute div children with React.memo. This optimization reduces unnecessary re-renders, enhancing efficiency.

Before memoization :- #860 (comment) After memoization :-

Image

Tell me please. Do you have an example, how you did it?

@soliyapintu
Copy link

soliyapintu commented Jan 9, 2025

@YuesIt17 You need to take extra div above <tr /> tag and you need to give all required virtulization styles to that div and inside that you need to render original row and need to wrap into React.memo. Make sure your <tr /> should not re-render when virtulization styles and props changes."

@wwesolowski
Copy link

@soliyapintu can you provide any codesandbox example of fixing the issue?

@remadyt
Copy link

remadyt commented Jan 30, 2025

@piecyk Im have same problem. Hi, do you have any solutions?

@mio-moto
Copy link

mio-moto commented Feb 7, 2025

We are struggeling with this issue for a while. We have a rather complex data table with a good amount of subnodes.

As I've been working on this problem, I've come to the understanding that there are multiple pitfalls:

  • if you're layouting your table with transform to position the virtualized items, then you should wrap the element with a outside style receiver or ref-logic to modify the transform property directly
  • your virtualized items create a lot of node pressure when scrolling really fast (if you pull on the scroll-bar you're entering a lot of react nodes into the DOM, all which pull down performance significantly:
    Image
  • React Devtools + Strict Mode are major performance downgrades, which has to be kept in mind.

Our current approach is like that:

// the forward-ref is important to make positioning work
const MemoizerRow = forwardRef<HTMLDivElement, { index: number; className?: string; style?: CSSProperties | undefined; children?: ReactNode }>(
  function MemoizerRow({ index, className, style, children }, ref) {
    return (
      <div className={cx('_row', memoizerClass, className)} ref={ref} data-index={index} style={style}>
        {children}
      </div>
    )
  },
)

// memo is important here, such as the actual discrete row does not get rebuilt
export const DetailsVariantRow: FC<{ entry: VariantDetailsRow; context: DetailsContext }> = memo(function DetailsVariantRow({ entry, context }) {
 // ...
  return (
    <div>
        { /* and all the things you need */ }
    </div>
  )
})

To mitigate JS runtime pressure, I'm currently working on a solution to measure the problem, but a simple PoC improves this already:

const VirtualizedGrid: FC< items: MyItem[] > = ({ items }) => { 
  const listRef = useRef<HTMLDivElement>(null)
  const getScrollElement = useCallback(() => listRef.current, [])
  const virtualizer = useVirtualizer({
    count: items.length,
    estimateSize: (idx) => estimateSizeImpl(items[idx]),
    overscan: 5,
    getScrollElement,
    paddingEnd: 300,
    rangeExtractor,
    enabled: !isPrint,
    onChange: (instance) => setVisible(10),
  })
  const [visible, setVisible] = useState(10)
  useEffect(() => {
    // increment every frame the amount of visible nodes until the screen is filled
    const func = () => {
      setVisible((curr) => {
        if(curr > virtualizer.getVirtualItems().length) {
          return curr;
        }
        return curr + 5 } )
      timer = requestAnimationFrame(func)
    }
    let timer = requestAnimationFrame(func)
    return () => cancelAnimationFrame(timer)
  }, [virtualizer])
}

All that's now missing is profiling the rendering pressure between frames (so you can dynamically change the display window) and have a virtualizer local lookup map which items have been rendered already and are ready from the memo cache, so that visible only counts new created items.

Another possible improvement is keeping a cache of rows that have been in view once, such that they can be restored even when they have been removed from the DOM by the virtualizer.

With that I get reasonable ~120fps even on full screen scrolls.

Edit: I also think that this has little to do with Firefox or Edge, but people forgetting that they haven't installed React Dev Tools on these browsers. Additionally Firefox' rendering strategy feels smoother, because the child layout is calculated asynchronously, while the node appearance (first draw of a virtualized node) is roughly in the same ballpark. Just that the window (and scroll) responsiveness doesn't degrade as a bad as with Chromium based browsers.

Another edit: Patching the onChange handler to not use flushSync actually improves my rendering performance by roughtly 2x:

  const virtualizer = useVirtualizer({
    count: displayData.length,
    estimateSize: (idx) => estimateSizeImpl(displayData[idx]),
    overscan: 5,
    getScrollElement,
    paddingEnd: 300,
    rangeExtractor,
    enabled: !isPrint,
    onChange: (instance) => {
      if (instance.scrollDirection) {
        insertionDirection.current = instance.scrollDirection
      }
    },
  })

  const rerender = useReducer(() => ({}), {})[1]
  // this small change gives a 2x performance, cool, ey?
  virtualizer.options.onChange = (instance) => {
    rerender()
  }

@MatchuPitchu
Copy link

Thank you for all your thoughts @mio-moto . Do you have a full code example in which your optimizations can be better understood? I don't understand your onChange patching. For example, where does insertionDirection.current come from and what do you do with this ref?

@mio-moto
Copy link

mio-moto commented Feb 8, 2025

@MatchuPitchu I have a working example here.

Be sure to click the "Open preview in new tab" button, otherwise React Scan won't show up. With that, you can compare the performance on your machine.

Image

Speedy

https://stackblitz.com/edit/tanstack-virtual-cdrheilm

20250208-1651-04.0482902.mp4

(Watch the FPS counter)

Sluggish

https://stackblitz.com/edit/tanstack-virtual-k17x9n25

20250208-1650-22.1334472.mp4

(Watch the FPS counter)

Some notes

  • roughly half the pressure comes from the layouter/compositor, which is due to this being a grid and rows inheriting from that. We have that in production like that and it's working "good enough", with the major benefit to not doing all the layouting calculation yourself.
  • all the rows that have been displayed in the current scroll are cached through memoization
  • I've toyed plenty with keeping a cache of all rows, but the primary pressure seems from DOM insertion on react (out of my hand), followed by layouter pressure (also out of my hand) and negligible are sorting and rasterization of rows (that, too, is out of my hand)
  • I think there can be done plenty on css hints about how the layout it supposed to do, but contain on rows sadly breaks subgrid positioning.
  • Performance can even now degrade. If you scroll plenty here, you will eventually hit JS/Chrome garbage collection, causing some stuttering, we've concluded from user tests that this is more a development-specific problem, nobody wildly scrolls up and down a lot
  • overscan can be increased to reduce the amount of "appearing" rows, that's mostly a trade-off of how much you want to clog the browser layouting stage
  • the example above lacks the overwriting of the virtualizer re-render, because something triggers immediate rerendering and I can't bother to debug that right now just for a demo
  • out production app similarly linearizes the rows first in memory and then displays like this, we have a similar structure to that.
  • out layout is plenty more complicated, which increases the layouter and DOM insertion pressure, with dev tools on, I can't get it beyond 120fps, which is quite poor to be fair
  • this thing is missing a sliding window of how many rows it inserts per frame max, I'll have to write some more sophisticated code detecting when a browser rolls below vsync.
  • maybe keeping a hot cache of rows may slightly add to the improvements, at least skipping the parent node of rows. I'd wager that the performance impact of just that one node is minimal.

@piecyk
Copy link
Collaborator

piecyk commented Feb 10, 2025

@mio-moto Wow, great work! Thanks for this detailed summary. Do you maybe have some ideas on how we can get some reliable numbers to test the performance and have something to compare?

Patching the onChange handler to not use flushSync actually improves my rendering performance by roughtly 2x:

Did you also test react 19 to see if it has a similarly big performance impact? We should definitely allow control over it.

@mio-moto
Copy link

mio-moto commented Feb 10, 2025

Sure, run your code in a cross-origin isolated environment for high precision dom timings and use this poorly implemented hook of mine:

import { useEffect, useRef } from 'react'

export const useFps = (sampleCount: number) => {
  const fps = useRef({
    fps: 0,
    max: 0,
    min: 0,
    frameTiming: 0,
    samples: [] as number[],
  })

  useEffect(() => {
    let lastTime = performance.now()

    const updateFps = (now: number) => {
      const delta = now - lastTime
      lastTime = now
      fps.current.samples.unshift(delta)
      if (fps.current.samples.length > sampleCount) {
        fps.current.samples.length = sampleCount
      }

      const frameTiming = fps.current.frameTiming * ((sampleCount - 1) / sampleCount) + delta / sampleCount
      const frameRate = 1000 / fps.current.frameTiming

      const sortedSamples = fps.current.samples.toSorted()
      const worst = sortedSamples.slice(0, 4)
      const best = sortedSamples.slice(sortedSamples.length - 6)
      const max = 1000 / (best.reduce((a, b) => a + b, 0) / best.length)
      const min = 1000 / (worst.reduce((a, b) => a + b) / worst.length)
      fps.current = {
        frameTiming,
        fps: frameRate,
        max: max,
        min: min,
        samples: fps.current.samples,
      }
      handler = requestAnimationFrame(updateFps)
    }
    let handler = requestAnimationFrame(updateFps)
    return () => cancelAnimationFrame(handler)
  }, [sampleCount])
  return fps
}

(there's some mishap about min/max frametiming, didn't care enough to fix it for looking at ballpark numbers.)

I'm updating a div container every time the table re-renders, which is good enough, then scroll (programmatically) a screen worth of rows (or pull the scroll bar) and you see a stark contrast.

I have a demonstration of that here - which is unfair to the case.

  • the slow table uses tanstack/table, where we figured out that we don't need all of its features
  • the slow table also uses a very different architecture, which prompted initially our investigation, because node insertion and creation of elements was very slow
  • the new table has also poor performance without all the optimizations in place, when scrolling like that
  • (the black bordered boxes are just blocks to hide customer data)
out.mp4

Did you also test react 19 to see if it has a similarly big performance impact? We should definitely allow control over it.

No, we've internally looked briefly at React19 and aren't in a hurry moving our code base to it, I've looked briefly at the changes to this specific problem and didn't notice anything major that prompted me to prioritize the move, either. (But my investigation was so shallow, that I would look at it again when I have migrated our code to the new table)

@piecyk
Copy link
Collaborator

piecyk commented Feb 11, 2025

@mio-moto I was playing around with your Speedy example, what about just limiting the overall re-rendering while scrolling, getting ~120fps on chrome while dragging. Something like https://stackblitz.com/edit/tanstack-virtual-qv5apjin

@mio-moto
Copy link

mio-moto commented Feb 11, 2025

I don't think that makes a great deal of a difference?

I'm running a chrome with disabled vsync (create shortcut, add the parameters --disable-gpu-vsync --disable-frame-rate-limit and I get roughly 240fps on a chrome with:

  • just rerender() instead of keepFps()
  • no react dev tools
  • disabled refresh indicator of React Scan
  • disabled vsync

Meanwhile your example clocks at roughly 200fps.

The problem ultimately is to guesstimate well how the table should behave. I'd argue that you cannot, even in a high refreshrate environment, tell if you're emitting at 60 or 120fps new nodes, as long as the scrollbehaviour is smooth (which can be achieved in Chrome with a translate3d(0, 0, 0) on the scroll element.

I think going forward, the trivial solution would be to be able to control how many children are emitted per frame (as part of the library) or going deeper into browser internals (with performance.mark for example) and automatically adjust a sliding window of how many rows are emitted.

Edit: Maybe a way to benchmark different solutions would be to refresh every frame and not memoizing. I could come up with a relatively complex layout alike of what we're doing in prod to stress the layouter.

@piecyk
Copy link
Collaborator

piecyk commented Feb 11, 2025

I don't think that makes a great deal of a difference?

Was thinking, if the rows are cheap to render, there should be less visible white space so the browser can keep up with rendering rather not rendering them.

I think going forward, the trivial solution would be to be able to control how many children are emitted per frame (as part of the library) or going deeper into browser internals (with performance.mark for example) and automatically adjust a sliding window of how many rows are emitted.

Hmm, basic skipping of rendering, like in your Speedy example: on drag, you limit how many new rows are rendered. In some cases, we end up with 3 or 6 rows instead of 30, and these can even be outside the visible range.

@mio-moto
Copy link

Yea, that's a few of the quarrels I have so far. The primary issue I've been really facing is coming up with good metrics when performance underflows a target.

I'm today in the process of migrating most of what is necessary off of tanstack/table and then I'll have another look at optimizing how to emit entries.

One of the problems I'm facing with the naive way without optimization is that the scroll-position of tanstack/virtual jerks around, because it can't keep up with layout calculations. Using smooth scroll behaviour also ends up often at the wrong position when the fps drops under 20fps.

@mio-moto
Copy link

mio-moto commented Feb 14, 2025

I'm now hunting down one more issue, which is related to how the virtualizer estimates (? maybe? maybe it's my own fault?), but I've written some code that looks like

performance.mark("begin-render")
const MyGrid: FC = () => {
  const rerender = useReducer(() => {
    performance.mark('end-render')
    console.log(performance.measure('render-delta', 'begin-render', 'end-render'))
    performance.mark('begin-render')
    return {}
  }, {})[1]
  const virtualizer = useVirutalizer({ /* ... */ });
  virtualizer.options.onChange = () => { rerender() }
}

Image
(notice the amount of render-delta marks in the timings row and how it gets interrupted by a lot of tiny bars, each of them being less than 100 microseconds)

I have there this very high frequency re-render calling that clogs the render pipeline (I'm guessing to estimate the box sizing?)

Edit: Yea, looking deeper into it, this is half of my own making and the onChange handler not being throttled.

My solution currently to all of this is to keep a requestAnimationFrame handler while the virtualizer is part of the view and check every frame if the handler has been invoked too recently and otherwise incrementally allow it to re-render more. In general it looks like that is architecturally more troublesome to fix, but I have spent little time inside the library code.

Edit 2: This is how it can work, indeed:

performance.mark('rerender-reducer')


export const VirtualizedGrid = (/* ... */): ReactNode => {
  const listRef = useRef<HTMLDivElement>(null)
  const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element>>()
  const isFirefox = navigator.userAgent.toLowerCase().includes('firefox')
  // on firefox, the node measurement and offsetting accounts for the zoom setting
  const zoom = useWindowZoom()
  const multiplier = isFirefox ? zoom.inverse : 1.0
  const estimateSizeImpl = useCallback(
    (row: Data[number]) => {
      if (!estimateSize) {
        return 47 * multiplier
      }
      return estimateSize(row)
    },
    [estimateSize, multiplier],
  )

  const getScrollElement = useCallback(() => listRef.current, [])
  const insertionDirection = useRef<'forward' | 'backward'>('forward')
  const virtualizer = useVirtualizer({
    count: displayData.length,
    estimateSize: (idx) => estimateSizeImpl(displayData[idx]),
    overscan: 5,
    getScrollElement,
    paddingEnd: 300,
  })

  const emitterDetails = useRef<{
    emitCount: number
    lastRerender: number
    observeRendering: boolean
    moreWork: boolean
  }>({
    emitCount: 1,
    lastRerender: performance.now(),
    observeRendering: true,
    moreWork: true,
  })

  // this small change gives a 2x performance, cool, ey?
  virtualizer.options.onChange = (instance) => {
    if (instance.scrollDirection) {
      insertionDirection.current = instance.scrollDirection
    }
    emitterDetails.current.moreWork = true
  }

  // monkey patching measureElement, because it is bugged on firefox
  const measureElementCallback = virtualizer.options.measureElement
  virtualizer.options.measureElement = (element, entry, instance) => {
    const result = measureElementCallback(element, entry, instance)
    return result * multiplier
  }
  setVirtualizerRef(virtualizer)
  virtualizerRef.current = virtualizer

  const size = virtualizer.getTotalSize()
  const rerender = useReducer(() => {
    const measure = performance.measure('render-delta-reducer', 'rerender-reducer')
    console.log(measure)
    performance.mark('rerender-reducer')
    return {}
  }, {})[1]
  // check every frame if there is more work, this can probably be made much cleaner
  useEffect(() => {
    const checkReducer = () => {
      if (emitterDetails.current.moreWork) {
        emitterDetails.current.moreWork = false
        rerender()
      }
      timer = requestAnimationFrame(checkReducer)
    }
    let timer = requestAnimationFrame(checkReducer)
    return () => {
      cancelAnimationFrame(timer)
    }
  }, [rerender])

  const virtualizedItems = virtualizer.getVirtualItems()
  const emitted = useRef<VirtualItem[]>([])

  // the entries have changed
  if (virtualizedItems.length !== emitted.current.length || virtualizedItems.some((item, i) => emitted.current[i] !== item)) {
    const nowEmitted: VirtualItem[] = []

    const lastTimeEmitted = [...emitted.current]

    // when backwards scrolling, the appending of nodes also needs to be backwards, otherwise their transition effects occur in the wrong order
    const elements = virtualizedItems
    let newEmitCount = isPrint ? Number.MAX_SAFE_INTEGER : emitterDetails.current.emitCount

    for (let i = 0; i < elements.length; i += 1) {
      const index = insertionDirection.current === 'forward' ? i : elements.length - 1 - i
      const element = elements[index]
      if (!lastTimeEmitted.some((x) => x.index === element.index)) {
        if (newEmitCount <= 0) {
          continue
        }
        newEmitCount -= 1
      }
      if (insertionDirection.current === 'forward') {
        nowEmitted.push(element)
      } else {
        nowEmitted.unshift(element)
      }
    }
    emitted.current = nowEmitted
    emitterDetails.current.moreWork = true
  }

  return (
    <div className="grid">
      <div className="scroll-list" ref={listRef}>
        <div className="table">
          {emitted.current.map((item) => {
              const entry = displayData[item.index]
              const translateOffset = headerHeight + item.start - virtualizer.options.scrollMargin
              const props = makeGridRowStyleProps(translateOffset)
              return (
                <RowComponent
                  key={entry.id}
                  className="_row"
                  ref={virtualizer.measureElement}
                  context={context}
                  translate={translateOffset}
                  entry={entry}
                  index={item.index}
                  style={props}
                />
              )
            })}
        </div>
      </div>
    </div>
  )
}

@piecyk
Copy link
Collaborator

piecyk commented Feb 14, 2025

@mio-moto there is also new option useAnimationFrameWithResizeObserver to wrap measurements called by ResizeObserver #923

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests