A Filament admin lands on a dashboard with four chart widgets. Each one polls every five seconds and runs an unindexed aggregation query. First paint takes three seconds because the page waits on every query before anything renders. The fix is small, and most of it ships with Filament v4 already — you just have to stop fighting the defaults. This is a Filament v4 chart widget polling and deferred-loading walkthrough that turns that sluggish dashboard into a fast first paint without giving up freshness.
The docs cover $pollingInterval and lazy loading in separate places, so people cargo-cult one and miss the compound win. Here's the whole thing in one pass.
Scaffold the chart widget#
Start with a real widget so the rest of the steps have something to attach to. The --chart flag gives you a ChartWidget subclass with getData() and getType() stubs already in place.
php artisan make:filament-widget RevenueChart --chart
That produces app/Filament/Widgets/RevenueChart.php. Fill in getData() with the aggregation you actually run — here, monthly revenue via flowframe/laravel-trend, which Filament recommends for model-derived chart data.
<?php
namespace App\Filament\Widgets;
use App\Models\Order;
use Filament\Widgets\ChartWidget;
use Flowframe\Trend\Trend;
use Flowframe\Trend\TrendValue;
class RevenueChart extends ChartWidget
{
protected ?string $heading = 'Revenue';
protected function getData(): array
{
$data = Trend::model(Order::class)
->between(start: now()->startOfYear(), end: now()->endOfYear())
->perMonth()
->sum('total'); // the expensive bit
return [
'datasets' => [[
'label' => 'Revenue',
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
]],
'labels' => $data->map(fn (TrendValue $value) => $value->date),
];
}
protected function getType(): string
{
return 'line';
}
}
Tune the chart widget polling interval to match data freshness#
This is the single highest-leverage change. By default the widget re-runs getData() every five seconds — fine for a live trading feed, absurd for revenue that moves a few times an hour. Override $pollingInterval with a string interval that reflects how often the underlying data actually changes.
// Refresh every 30 seconds instead of every 5
protected ?string $pollingInterval = '30s';
If the data is effectively static for the duration of a session — a year-to-date total, a historical breakdown — turn polling off entirely. A null interval means the chart renders once and never re-queries.
protected ?string $pollingInterval = null;
On a four-widget dashboard, dropping from a 5-second to a 30-second interval cuts background query load by 83% before you touch anything else.
Cache the data query against the polling TTL#
Polling less often helps, but each poll still hits the database. Wrap the aggregation in cache()->remember() with a TTL that matches your polling interval, and the work runs once per window no matter how many browser tabs are open or how many widgets share the same source. This is the same instinct behind caching heavy queries across requests in Livewire — push the expensive computation behind a key with a sensible lifetime.
protected function getData(): array
{
// TTL (30s) matches $pollingInterval so every poll after the
// first reads from Redis until the data is allowed to go stale.
$data = cache()->remember('revenue-chart-data', now()->addSeconds(30), function () {
return Trend::model(Order::class)
->between(start: now()->startOfYear(), end: now()->endOfYear())
->perMonth()
->sum('total');
});
return [
'datasets' => [[
'label' => 'Revenue',
'data' => $data->map(fn (TrendValue $value) => $value->aggregate),
]],
'labels' => $data->map(fn (TrendValue $value) => $value->date),
];
}
Keep the TTL at or below the polling interval. A cache that outlives the poll just means the chart shows stale numbers between refreshes for no benefit.
Keep lazy loading on for a fast first paint#
Here's where the old advice goes stale. In Filament v4, widgets are lazy-loaded by default — the dashboard shell renders immediately and each widget fetches its data in a follow-up request via wire:init. There's no HasInteractedWith interface or getLoadingIndicator() to wire up anymore; that was a Filament v3-era pattern. The fast first paint is the default, so the real job is making sure you haven't disabled it.
// This is the v4 DEFAULT. You only write this line to TURN OFF
// lazy loading — which you almost never want on a slow widget.
protected static bool $isLazy = true;
If you've inherited a dashboard that paints slowly, check for protected static bool $isLazy = false; on the widgets and delete it. That single line forces the expensive query to block the initial render — exactly the three-second stall from the intro. The deferred-render mechanics here are the same idea as lazy-loading expensive components with Livewire islands: paint the frame first, fill the slow parts after.
Defer filter updates until the user clicks Apply#
If your chart has a filter form via HasFiltersSchema, every keystroke or date change re-queries by default. On an expensive aggregation that's a poll storm of its own. Set $hasDeferredFilters to true so changes only apply when the user clicks the Apply button — the chart still shows meaningful data on first load using the filter defaults.
use Filament\Widgets\ChartWidget\Concerns\HasFiltersSchema;
class RevenueChart extends ChartWidget
{
use HasFiltersSchema;
protected bool $hasDeferredFilters = true;
// ... getData() reads $this->filters as before
}
For dashboard-level filters that drive several widgets at once, reach for the InteractsWithPageFilters trait instead and read $this->pageFilters inside getData(). Note the v4 rename: it's $this->pageFilters, not the $this->filters you may remember from v3. The same building blocks show up when you build a custom Filament form field, since the filter form is just a schema.
Switch to event-driven refresh with Reverb#
Polling is a poll-every-N-seconds compromise for data you can't subscribe to. When you can — orders, payments, anything you already broadcast — drop polling and let the broadcast push the refresh. Set $pollingInterval = null, then add a listener method decorated with Livewire's #[On] attribute that calls $this->updateChartData().
use Livewire\Attributes\On;
class RevenueChart extends ChartWidget
{
protected ?string $pollingInterval = null; // no polling — events drive refreshes
#[On('echo:orders,OrderCreated')]
public function refreshOnOrder(): void
{
// Re-runs getData() and pushes the new dataset to Chart.js
// without re-rendering the whole dashboard layout.
$this->updateChartData();
}
}
Now the chart is silent until an OrderCreated event fires over your Reverb connection, at which point it refreshes once. If you haven't set up the broadcast side yet, the wiring for the Echo channel and the event is covered in real-time notifications with Laravel Reverb and Echo.
Gotchas and Edge Cases#
A few things that bite in production:
Polling intervals are strings, not integers. '30s', '2m', '1h' are valid; a bare 30 will not behave the way you expect. Stick to the suffixed string form.
The cache key must be unique per data variant. If the same widget renders different data per tenant or per filter, fold the discriminator into the key — "revenue-chart-{$tenantId}" — or every tenant sees the first one's numbers until the TTL expires.
$isLazy is a static property. Overriding it without static silently does nothing. Copy the signature exactly.
updateChartData() re-runs getData(), so it reads through your cache. If you want an event to show truly fresh numbers, cache()->forget('revenue-chart-data') inside the listener before calling updateChartData(), otherwise the event just shows whatever's already cached.
Lazy loading defers the query but not authorization. A widget that's expensive and gated should still resolve its canView() cheaply, because that check runs before the lazy fetch.
Wrapping Up#
Set $pollingInterval to match how fast the data really changes, cache getData() against that same window, and leave v4's default lazy loading alone. Those three moves take a four-widget dashboard from a three-second stall to an instant shell with charts that fill in behind it. Reach for #[On] plus updateChartData() only when you have a broadcast to subscribe to — it beats polling every time the data is event-shaped.
If you're assembling a dashboard from scratch, the complete Filament v4 zero-to-production dashboard guide covers the panel and resource wiring around these widgets. And when you want the loading state to feel deliberate rather than blank, skeleton loaders that match your island layout pair well with lazy-loaded widgets.
FAQ#
How do I change the polling interval on a Filament chart widget?
Override the $pollingInterval property on the widget class with a string interval such as '30s' or '2m'. The default is a five-second refresh. Use a value that reflects how often the underlying data actually changes — there's rarely a reason to re-query revenue figures every five seconds.
What is deferred loading in Filament v4?
In Filament v4, widgets are lazy-loaded by default: the dashboard shell renders first and each widget fetches its data in a follow-up request, so the page paints fast even when a widget runs a slow query. The older HasInteractedWith and getLoadingIndicator() approach from v3 is gone — you get the deferred render for free unless you've disabled it with $isLazy = false. Note this is separate from "deferred filters" ($hasDeferredFilters), which delays filter changes until the user clicks Apply.
How do I cache a Filament widget's query?
Wrap the query inside getData() in cache()->remember() with a key and a TTL, returning the computed dataset from the closure. Match the TTL to your polling interval so each refresh window runs the query once and every subsequent poll reads from the cache. Remember to make the cache key unique per tenant or filter variant so different users don't see each other's data.
Can I disable polling on a Filament widget?
Yes. Set protected ?string $pollingInterval = null; on the widget and it renders once without re-querying on a timer. This is the right choice for data that doesn't change during a session, or when you plan to refresh the widget from a broadcast event instead of a clock.
How do I refresh a Filament widget when a broadcast event fires?
Disable polling, then add a public method decorated with Livewire's #[On] attribute listening for your event — for example #[On('echo:orders,OrderCreated')] — and call $this->updateChartData() inside it. The chart re-runs getData() and pushes the new dataset to Chart.js without re-rendering the rest of the dashboard. If you need genuinely fresh numbers, forget the cache key inside the listener before calling updateChartData().
Why is my Filament dashboard slow on first load?
The usual cause is a widget with lazy loading disabled ($isLazy = false) running an expensive, uncached query that blocks the initial render. Re-enable lazy loading by removing that line, cache the aggregation in getData(), and raise the polling interval. Each fix is independent, but together they take first paint from seconds to instant.