Svelte 5 is officially out, and it introduces Runes: a completely new way to handle reactivity.
Most tutorials show you how to build a "Counter" app with $state. That's cool, but I wanted to see how it performs in the trenches.
So, I built LogTide entirely with SvelteKit 5. It's a log management dashboard (like Datadog) that streams logs in real-time via Server-Sent Events (SSE).
Here's what I learned about moving from Stores to Runes in a data-heavy application.
The Challenge: The "Live Tail"
Imagine a WebSocket or SSE connection pumping 50-100 log lines per second into your frontend. In Svelte 4, you would likely use a writable store and subscribe to it.
The problem? Array mutations and large object updates can trigger unnecessary re-renders of parent components if you aren't careful.
The Solution: $state and Fine-Grained Reactivity
With Runes, reactivity is opt-in and fine-grained. Here's how I structured the log ingestion.
1. The Log Store (Simplified)
Instead of a store, I just use a class or a function that returns a state object.
// logs.svelte.ts
export class LogStream {
// Look how clean this is. No 'writable', just $state.
logs = $state([]);
isStreaming = $state(false);
constructor() {
this.connect();
}
connect() {
this.isStreaming = true;
const eventSource = new EventSource('/api/v1/stream');
eventSource.onmessage = (event) => {
const newLogs = JSON.parse(event.data);
// Direct mutation! No need for: logs.update(l => [...l, ...new])
// Svelte 5 proxies the array and updates only the DOM nodes that care.
this.logs.unshift(...newLogs);
// Keep array size manageable
if (this.logs.length > 1000) {
this.logs.length = 1000;
}
};
}
}
2. Derived State for Filtering
This is where Runes shine. In a log dashboard, you filter by level (Info, Error) or service. Previously, derived stores could be clunky to chain. Now, it's just $derived.
// filtering.svelte.ts
let searchQuery = $state('');
let levelFilter = $state('ALL');
// This re-calculates ONLY when searchQuery or levelFilter changes.
// It does NOT re-calculate if 'someOtherState' changes.
const filteredLogs = $derived(
allLogs.filter(log => {
const matchesLevel = levelFilter === 'ALL' || log.level === levelFilter;
const matchesQuery = log.message.includes(searchQuery);
return matchesLevel && matchesQuery;
})
);
3. UI Performance with shadcn-svelte
I used the shadcn-svelte port for the UI. Since shadcn components are headless/accessible, combining them with Svelte 5 was mostly seamless, although some older libraries still rely on slot syntax (which is deprecated in favor of snippets, but still supported).
The result is a dashboard that feels native. I can have the "Live Tail" open receiving 100 logs/sec, and the UI remains 60fps responsive because Svelte is surgically updating text nodes, not re-rendering the whole data table component tree.
Verdict: Is Svelte 5 Ready?
For my use case: Yes. The code is cleaner. I deleted so much boilerplate code (subscribing/unsubscribing to stores). The mental model of "It's just JavaScript variables" makes handling complex state logic much easier.
If you want to see the full source code (it's a monorepo with Fastify backend), check it out on GitHub. I'd love feedback on my Runes implementation!
📦 Source Code: https://github.com/logtide/logtide
🚀 Live App: https://logtide.dev
Top comments (1)
Really enjoyed the field-report framing here. Most reactivity write-ups never push the system that hard.
I was wondering, when you call
this.logs.unshift(...newLogs)100 times a second, is$derived(allLogs.filter(...))recomputing the full filter on every single mutation, or is Svelte's reactivity doing something smarter at the array-mutation level so the derived only refires when the filter result actually changes? I have no idea how runes track this under the hood.Did you see anything in profiling that surprised you on the derived side? Curious what the cost actually looked like at peak.