DEV Community

Cover image for How I Got a 1,000-Row Filament Table from 10.9 s to 1.1 s with Canvas
qalainau
qalainau

Posted on

How I Got a 1,000-Row Filament Table from 10.9 s to 1.1 s with Canvas

Disclosure: I'm the author of Warp Table, a commercial Filament plugin. The measurements below come from its public demo, which you can use to run the same comparison in your own browser.

Filament tables are a joy to build. A few lines of PHP give you sorting, search, filters, bulk actions, and inline editing.

Then a client asks to see 1,000 rows on one page, with editable prices and stock levels. A table that felt instant at 25 rows now takes seconds to load.

Pagination is usually the right answer. But some workflows depend on scanning and editing a long list without switching pages: order entry, price updates, inventory counts. Those were the screens I wanted to improve.

So I built a renderer that draws the table rows on a <canvas> while keeping Filament's surrounding controls. In my demo, the 1,000-row page's load time went from 10.85 s to 1.10 s.

Drawing the rows turned out to be the easy part. Preserving the interactions people expect from a table took most of the work.

The numbers

The demo includes a page called Speed Lab. It renders the same table using either Filament's native renderer or Warp Table.

The table has 10 columns, seven of them editable: three text inputs, two selects, a toggle, and a checkbox.

After each full page load, Speed Lab records the DOM node count, decoded HTML size, and time to the browser's load event. Here are the results from my measurements:

Rows Renderer DOM nodes Decoded HTML Page load
500 Native 46,641 19.2 MB 3.89 s
500 Warp Table 1,090 395 KB 0.84 s
1,000 Native 92,849 38.2 MB 10.85 s
1,000 Warp Table 1,590 655 KB 1.10 s
2,500 Native — — PHP out of memory
2,500 Warp Table 3,081 1.4 MB 1.53 s

At 2,500 rows, the native version hit the demo server's 256 MB PHP memory limit while generating the HTML. That is a limit of this test environment, not a universal row limit for Filament.

These are full-page load measurements, not isolated canvas draw times or a direct measurement of when every control becomes interactive. The HTML figures are decoded sizes, not compressed network transfer sizes. Results will vary with the browser, hardware, server, network, and cache state.

Speed Lab measurements for 500, 1,000 and 2,500 rows

Where the weight comes from

An editable table does much more than display text. Each cell needs its own state, event handling, loading indicators, and validation behavior.

For example, a TextInputColumn renders an Alpine component for each cell. The structure looks roughly like this, with details omitted:

<div
    wire:ignore.self
    x-load
    x-load-src="…/columns/text-input.js"
    x-data="textInputTableColumn({ name: 'price', recordKey: '42', state: 1250 })"
    class="fi-ta-text-input"
>
    <input type="hidden" value="1250" x-ref="serverState">

    <div
        x-bind:class="{ 'fi-disabled': isLoading, 'fi-invalid': error !== undefined }"
        x-tooltip="…"
        x-on:click.prevent.stop
        class="fi-input-wrp"
    >
        <div class="fi-input-wrp-content-ctn">
            <input
                type="number"
                wire:loading.attr="disabled"
                wire:target="…"
                x-bind:disabled="isLoading"
                class="fi-input"
            >
        </div>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

SelectColumn, ToggleColumn, and CheckboxColumn add their own markup and behavior. A native select also repeats its <option> list in every row.

With seven editable columns across 1,000 rows, the Speed Lab table has about 7,000 Alpine components. The measured page contains roughly 93 DOM nodes per displayed row, including the surrounding UI.

The server has to generate the markup, the browser has to parse it, and Alpine has to initialize the components. The benchmark does not isolate the time spent at each stage, but it shows how much work a large editable table asks the stack to do.

Keep Filament, replace the rows

Warp Table uses Table::content() to replace the records area while keeping Filament's header, toolbar, filters, pagination, bulk actions, and modals.

For supported tables, enabling it takes one extra method call:

$table
    ->columns([
        // Your existing column definitions.
    ])
    ->warp();
Enter fullscreen mode Exit fullscreen mode

Instead of rendering a Blade view for every cell, the server serializes the current page into a compact JSON payload: cell data, column definitions, groups, and summaries. Row action markup is templated once with a placeholder for the record key instead of being repeated for every record.

That change is what brings the decoded HTML size down from 38.2 MB to 655 KB in the 1,000-row test.

One unexpected problem was where to put the JSON. I initially used a <script> tag. In the Livewire debug setup I was testing, the single-root-element check stripped script contents with a regular expression. With PCRE's JIT disabled, that check failed when the script grew beyond roughly 1 MB.

Moving the payload into a hidden <textarea> avoided the failure in that setup. It was a small implementation detail that cost me an afternoon.

Matching the theme

Canvas drawing does not automatically inherit the appearance of Filament's HTML components. I wanted the rows to follow custom themes and dark mode without maintaining a separate palette.

The renderer gets its styling from the browser:

  • Colors, fonts, and spacing come from hidden probe elements with Filament's classes, read through getComputedStyle. The canvas uses those values when drawing the rows.
  • Column widths come from a hidden HTML table built with Filament's markup and a sample of rows. The browser calculates the layout, and the canvas uses the resulting widths. This also lets the measurement reflect relevant application CSS and extraAttributes() styling.

Rather than trying to reproduce the browser's layout rules in JavaScript, I let the browser do that work.

Preserving the interactions

Replacing DOM rows with pixels means rebuilding interactions that HTML normally gives you for free.

Links

Real links support Cmd/Ctrl-click, middle-click, context menus, and URL previews in the status bar.

To preserve those behaviors, a transparent <a> element follows the pointer over link cells. The browser handles the link interaction instead of a canvas click handler trying to imitate it.

Editing

When a cell enters edit mode, the renderer places a real input over it, using Filament's classes. This keeps text entry in a DOM element rather than trying to implement an editor inside the canvas.

Saving goes through Filament's updateTableColumnState, the same method used by native editable columns.

Keyboard navigation

The renderer maintains a virtual list of focus targets in the order users would encounter them in the native table: the page checkbox, sortable headers, group checkboxes, then each row's checkbox, links, fields, and actions.

Tab moves through those targets, focus indicators use styles taken from the native components, and Tab leaves the table at either end. This supports keyboard navigation, but it does not give canvas rows the accessibility semantics of a native table—a limitation I return to below.

Selection and bulk actions

Row selection uses Filament's own Alpine store, so Shift-click ranges, “select all,” and bulk actions stay connected to Filament's selection state.

Browser find

Text drawn on a canvas is not searchable DOM text. To support Cmd/Ctrl+F, the renderer adds a transparent text layer containing all rows on the current page when that shortcut is pressed.

The layer is aligned with the canvas so the browser can highlight matches and scroll to them. It is a workaround for browser find, not a replacement for an accessible table structure.

Every demo page has a Compare with native button. I use it to check the same table and interactions with both renderers.

What recording the screen revealed

Two scrolling bugs became obvious when I reviewed recordings frame by frame.

A sticky header that jittered

Filament wraps the table in a horizontally scrollable container (.fi-ta-content-ctn has overflow-x: auto), so position: sticky on the header can only stick inside that container, never to the page viewport. That is why the native table has no sticky header while the page scrolls.

My first canvas implementation repositioned the header on every scroll event. But scrolling can advance on the compositor thread before the main thread updates the drawing. For a frame, the header would move with the page, then jump back into place.

In one recording, 154 of 331 frames showed the header displaced by about 10 px.

The fix was to draw the pinned header on a separate position: fixed canvas. That let the browser keep it in place without waiting for a JavaScript position update. In the follow-up recording, I observed the issue in 0 of 331 frames.

A blank band during fast scrolling

The rows canvas initially covered exactly the viewport. During fast scrolling, the browser could reveal an area before the next redraw filled it.

In one recording, this produced a blank band in 106 of 598 frames, reaching up to 215 px.

Drawing an extra 400 px above and below the viewport eliminated the visible gap in the follow-up recording: 0 of 598 frames. The trade-off was an increase in draw time from about 0.3 ms to 0.6 ms in my measurements.

Those results describe the recordings I checked, rather than a guarantee for every device or scroll speed.

A rendering engine that never rested

A profiler revealed another problem: I had stored the rendering engine in Alpine's reactive data.

In that implementation, proxy access and a reactive effect created a feedback loop. The table kept redrawing even while idle—about 175 times per second in the trace I inspected.

Moving the engine out of reactive state fixed it. The UI needed reactive state; the rendering engine itself did not need to be reactive.

The trade-offs

Canvas solves some performance problems by giving up features the DOM provides automatically. These are the main limitations:

  • Screen readers cannot read the drawn rows as a native table. Users who rely on that structure should use the native renderer. ->warp() accepts a closure, so an application can make the choice follow a user preference.
  • You cannot drag-select individual words in the drawn rows. Warp Table 1.1 adds spreadsheet-style cell selection instead: drag across cells and copy tab-separated text into Excel or Google Sheets. That is useful for tabular data, but it is a different interaction from selecting text.
  • Some configurations use the native renderer automatically. These include column layouts such as Split and Stack, reorder mode, groups-only tables, and empty results.

For a typical table with a small page size, I would keep the native renderer. Warp Table is aimed at screens where displaying and editing hundreds or thousands of rows on one page is a workflow requirement.

Try the comparison

The demo requires no sign-up, and each page includes Compare with native:

If you've worked on a Filament table that became slow, what did it look like? How many rows did users need on one page, which columns were editable, and why did that workflow need a long table?

Those details help me decide what to support next.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments.