Filament Table Builder: Deferred Loading and Pagination for Large Datasets
Filament v3's deferLoading() postpones expensive table queries until user interaction. Combine it with scoped queries, eager loading, and pagination defaults to keep admin panels responsive on 10k+ row datasets — but verify the query itself isn't the bottleneck.
22 Sept 2025, 00:17 UTC

The Problem: Admin Panels That Hang on Large Tables
You open a Filament resource listing 50,000 orders. The browser spins. Time to First Byte stretches past three seconds because the table builder executes a heavy query — joins, scopes, eager loads — before rendering a single row. Users perceive the panel as broken. The fix isn't pagination alone; it's deferring that initial query until the user actually asks for data.
Deferred Loading: What It Does and When to Use It
Filament v3's deferLoading() method on the Table Builder postpones the base query until the first pagination, sort, or filter interaction. On initial page load, the table renders empty (or with a placeholder you provide) and the expensive SQL never runs. This is distinct from lazy loading relationships — it defers the entire table query.
Use it when:
- The base query touches multiple tables or uses heavy scopes
- TTFB matters more than immediate data visibility
- Users typically filter or search before scanning rows
Avoid it when users expect instant feedback — a blank table with no context feels broken. Pair it with a skeleton loader or a emptyStateHeading that explains the view.
Pagination Configuration That Scales
Pagination in Filament wraps Laravel's LengthAwarePaginator, so links() rendering and query-string preservation for filters/sorting work out of the box. Configure page-size options with paginated([10, 25, 50, 100]) and set a sensible default via defaultPaginationPageOption(25). The array is static; if you need per-user preferences (e.g., a "50 per page" setting saved to the user model), you'll need a custom PaginationPageOption implementation or middleware that injects the value into the request before the table resolves.
Combine pagination with a scoped base query and eager loading to prevent N+1 during row rendering:
protected function getTableQuery(): Builder
{
return Order::query()
->where('tenant_id', auth()->user()->tenant_id)
->with(['customer', 'items.product']);
}
The with() clause runs only when the deferred query finally executes, so you pay the eager-load cost once per page, not per row.
Worked Example: Orders Resource with 10k+ Rows
Below is a complete OrderResource table configuration that demonstrates deferred loading, pagination defaults, sorting, and a bulk delete action that preserves page state.
use Filament\Tables;
use Filament\Tables\Table;
use Filament\Resources\Resource;
use App\Models\Order;
class OrderResource extends Resource
{
protected static ?string $model = Order::class;
public static function table(Table $table): Table
{
return $table
->query(fn (Builder $query) => $query
->where('tenant_id', auth()->user()->tenant_id)
->with(['customer', 'items.product'])
)
->deferLoading()
->paginated([10, 25, 50, 100])
->defaultPaginationPageOption(25)
->defaultSort('created_at', 'desc')
->emptyStateHeading('Orders load when you sort, filter, or paginate')
->columns([
Tables\Columns\TextColumn::make('id')
->searchable()
->sortable(),
Tables\Columns\TextColumn::make('customer.name')
->label('Customer')
->searchable(['first_name', 'last_name'])
->sortable(),
Tables\Columns\TextColumn::make('total')
->money('currency')
->sortable(),
Tables\Columns\TextColumn::make('created_at')
->dateTime()
->sortable(),
])
->filters([
Tables\Filters\SelectFilter::make('status')
->options(Order::STATUSES),
])
->bulkActions([
Tables\Actions\BulkAction::make('delete')
->label('Delete selected')
->icon('heroicon-o-trash')
->requiresConfirmation()
->deselectRecordsAfterCompletion()
->action(fn (Collection $records) => $records->each->delete()),
]);
}
}
Key points in this example:
deferLoading()sits after the query closure — the closure defines what runs, deferLoading controls when.emptyStateHeadingsets expectations; without it, users see a blank table with no hint why.deselectRecordsAfterCompletion()on the bulk action keeps the current page and filters intact after deletion.- The
with()eager load runs only once per paginated request, not per row.
Trade-offs: What Deferred Loading Doesn't Solve
Deferred loading moves the query cost; it doesn't eliminate it. If your base query uses complex joins, subqueries, or unindexed columns, the delay simply shifts from page load to the first user interaction. You'll still need:
- Proper indexes on filtered/sorted columns (
created_at,status,tenant_id) - Query optimization — check
EXPLAINoutput for the generated SQL - Consider materialized views or summary tables for genuinely massive datasets
Also, pagination page options are a static array. Dynamic per-page values (e.g., "show 75 because that's my saved preference") require a custom PaginationPageOption class or middleware that rewrites the perPage request parameter before the table builder reads it.
Verify Before You Ship
Don't assume it works — measure. In a local environment with a seeded dataset (>10k rows):
- Open the resource page with
deferLoading()enabled. Open browser dev tools → Network tab. Note TTFB and that no table query appears in the response. - Click a column header to sort. Confirm the query executes and returns only the first page.
- Run
DB::enableQueryLog()in a test ortinkersession, trigger the table request, and inspectDB::getQueryLog()to verify the base query runs only on interaction. - Test bulk actions across pages: select rows on page 1, navigate to page 2, trigger the bulk delete, and verify the table returns to page 1 with correct pagination state.
If TTFB improves but the first interaction feels sluggish, the query itself needs tuning — deferred loading just bought you time to fix it without blocking the initial render.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.