· 11 Min read

Remote Functions Batching: Solving N+1 in SvelteKit

Remote Functions Batching: Solving N+1 in SvelteKit

SvelteKit dropped some focused updates in October 2025 that directly address performance bottlenecks I've been wrestling with since Remote Functions landed. The headline feature is query.batch, which solves the n+1 problem that creeps into any app displaying lists of items with relational data. I also got lazy discovery for functions in node_modules, which cut my production bundle by 18KB, and schema-powered form enhancements that finally make validation feel native.

I'll walk through each update with real code, show where they matter, and explain the trade-offs I ran into while refactoring a production dashboard.

Link to section: The N+1 Problem in Remote FunctionsThe N+1 Problem in Remote Functions

Here's the scenario. You have a list of blog posts, and each post shows its author's name and avatar. The naive approach loops over posts and fires a separate getAuthor(userId) query for each one. With 20 posts, that's 20 database reads, most of which probably fetch the same users multiple times.

Before October, I handled this by pre-fetching authors in the parent load function and passing them down as props. That worked but violated the whole point of Remote Functions, which let components own their data needs. It also meant I couldn't reuse <AuthorCard> in other contexts without duplicating fetch logic.

Here's what that looked like in September:

<script>
  import { getAuthor } from '$lib/data.remote';
  
  let { userId } = $props();
  let author = $derived(await getAuthor(userId));
</script>
 
<div>
  <img src={author.avatar} alt={author.name} />
  <span>{author.name}</span>
</div>

Rendering 20 posts with this component triggered 20 individual requests. Network tab showed them firing one after another, total time around 1.2 seconds on a decent connection.

Link to section: Introducing query.batchIntroducing query.batch

As of SvelteKit 2.38.0, query.batch collects calls within the same macrotask and sends them in a single HTTP request. On the server, your callback receives an array of inputs and returns a resolver function. SvelteKit maps each input to its result.

Here's the updated author query using query.batch:

// src/lib/data.remote.ts
import { query } from '$app/server';
import * as db from '$lib/server/database';
import * as v from 'valibot';
 
export const getAuthors = query.batch(
  v.number(),
  async (userIds) => {
    const users = await db.sql`
      SELECT * FROM users
      WHERE id = ANY(${userIds})
    `;
    
    const lookup = new Map(users.map(u => [u.id, u]));
    
    return (userId, idx) => lookup.get(userId);
  }
);

The component code stays identical. You still call getAuthors(userId) individually, but SvelteKit batches them under the hood. The lookup Map ensures O(1) resolution for each user ID.

When I deployed this, the network tab showed a single POST to /__data.remote.json carrying an array of user IDs. Response time dropped from 1.2 seconds to 180ms. The database went from 20 SELECT queries to one WHERE id = ANY(...) query.

Link to section: When Batching Helps and When It Doesn'tWhen Batching Helps and When It Doesn't

Batching shines when you render lists where each item needs related data and those relations overlap. I've measured meaningful wins in these cases:

  • Blog post lists with author info (15+ posts, 3-5 unique authors)
  • E-commerce product grids showing seller details (50+ products, 10-20 sellers)
  • Comment threads with user avatars (40+ comments, 8-12 users)

It doesn't help if your queries are already unique per item or if you fetch data that isn't reused. For example, fetching full post content by slug won't benefit because each slug is different.

Network waterfall showing 20 individual requests vs one batched request

One gotcha: if your database doesn't support batch queries efficiently, you might move the bottleneck from network to database. Postgres handles ANY(...) well, but I've seen SQLite struggle with large arrays. Test with realistic data volumes.

Link to section: Lazy Discovery of Remote FunctionsLazy Discovery of Remote Functions

Before October, SvelteKit scanned your entire project for .remote.ts files at build time. If you imported a third-party library that exposed Remote Functions—say, a CMS SDK—those functions ended up in your client bundle even if you never called them.

SvelteKit 2.39.0 introduced lazy discovery. Functions defined in node_modules are now only bundled if your code actually references them. This improved tree-shaking cut 18KB from my production bundle after I added a headless CMS package that exported 30+ query functions but I only used three.

You don't need to configure anything. Just update to 2.39.0 or higher and rebuild. Check your bundle size before and after:

npm run build
du -sh .svelte-kit/output/client/_app

I went from 142KB to 124KB for the main chunk. That's a 12% reduction, which directly improved First Contentful Paint by about 50ms on 3G.

Link to section: Schema-Powered Forms with input and issuesSchema-Powered Forms with input and issues

Form validation got tighter in SvelteKit 2.42.0. The form remote function now accepts a schema and exposes input and issues properties. This replaces the pattern where you manually tracked validation errors in component state.

Here's a login form before October:

<script>
  import { login } from '$lib/data.remote';
  
  let email = $state('');
  let password = $state('');
  let errors = $state({});
  
  async function handleSubmit() {
    const result = await login({ email, password });
    if (result.errors) {
      errors = result.errors;
    }
  }
</script>
 
<form onsubmit={handleSubmit}>
  <input name="email" bind:value={email} />
  {#if errors.email}
    <span>{errors.email}</span>
  {/if}
  <input name="password" type="password" bind:value={password} />
  {#if errors.password}
    <span>{errors.password}</span>
  {/if}
  <button type="submit">Login</button>
</form>

Now with schema support:

// src/lib/data.remote.ts
import { form } from '$app/server';
import * as v from 'valibot';
 
export const login = form(
  v.object({
    email: v.pipe(v.string(), v.email()),
    password: v.pipe(v.string(), v.minLength(8))
  }),
  async (data, invalid) => {
    const user = await db.findUserByEmail(data.email);
    if (!user || !await verifyPassword(data.password, user.hash)) {
      invalid(invalid.email('Invalid email or password'));
      return;
    }
    // Set session cookie, etc.
  }
);

In the component:

<script>
  import { login } from '$lib/data.remote';
</script>
 
<form {...login}>
  <label>
    Email
    <input {...login.fields.email.as('text')} />
    {#each login.fields.email.issues() as issue}
      <span>{issue.message}</span>
    {/each}
  </label>
  
  <label>
    Password
    <input {...login.fields.password.as('password')} />
    {#each login.fields.password.issues() as issue}
      <span>{issue.message}</span>
    {/each}
  </label>
  
  <button type="submit">Login</button>
</form>

The {...login} spread sets method="POST" and action. The as() method returns the right type, name, and aria-invalid attributes. The issues() method gives you validation errors scoped to that field.

This works with progressive enhancement by default. If JavaScript fails to load, the form submits normally and server-side validation still runs. Errors appear after page reload.

I prefer this to superforms for simple cases because it's built-in and has zero dependencies. For complex multi-step wizards, superforms still wins on features.

Link to section: Async SSR Lands ExperimentallyAsync SSR Lands Experimentally

SvelteKit 2.43.0 pairs with Svelte 5.39.3 to enable async SSR. You opt in via compilerOptions.experimental.async in svelte.config.js. Once enabled, you can await anywhere in your component without wrapping it in a boundary.

Before this, async data in components required {#await} blocks or svelte:boundary with pending snippets. That pattern forced you to handle loading states even when you wanted the server to wait for data before sending HTML.

Now you write:

<script>
  import { getPost } from '$lib/data.remote';
  
  let { slug } = $props();
  let post = $derived(await getPost(slug));
</script>
 
<h1>{post.title}</h1>
<div>{@html post.content}</div>

On the server, SvelteKit waits for getPost to resolve before rendering the page. On the client, it fetches in the background and updates when ready. You don't write separate loading UI unless you want it.

I tested this on a blog detail page. Before async SSR, I used a {#await} block that showed a skeleton loader on client navigation. With async SSR, the server sends complete HTML and the skeleton never appears. Perceived load time dropped because the browser can start painting immediately.

One caveat: if your data fetch is slow, the server holds the response. Make sure your queries are fast or use streaming (which async SSR supports but requires more setup).

For more on how async components change patterns, see our async SSR deep dive.

Link to section: Single-Flight Mutations RevisitedSingle-Flight Mutations Revisited

Remote Functions already supported single-flight mutations before October, but the workflow got cleaner with the schema updates. Single-flight mutations refresh queries on the server during form submission, so the client receives fresh data in the same response.

Here's a comment form that refreshes the post's comment count:

// src/lib/data.remote.ts
import { query, form } from '$app/server';
import * as v from 'valibot';
 
export const getCommentCount = query(
  v.number(),
  async (postId) => {
    const result = await db.sql`
      SELECT COUNT(*) FROM comments WHERE post_id = ${postId}
    `;
    return result[0].count;
  }
);
 
export const addComment = form(
  v.object({
    postId: v.number(),
    content: v.pipe(v.string(), v.minLength(1))
  }),
  async (data) => {
    await db.sql`
      INSERT INTO comments (post_id, content) VALUES (${data.postId}, ${data.content})
    `;
    await getCommentCount(data.postId).refresh();
  }
);

When the form submits, SvelteKit runs addComment, which inserts the comment and calls refresh() on the count query. The response includes the updated count, so the UI updates without a separate fetch.

I measured this on a post with 50 comments. Without single-flight mutations, submitting a comment took one POST for the form and one GET for the refreshed count—total 380ms. With single-flight, it's one POST at 240ms. Not dramatic, but it eliminates a round trip and keeps the UI snappy.

Link to section: Optimistic Updates with withOverrideOptimistic Updates with withOverride

The October updates didn't change withOverride, but I'll mention it because it pairs well with batching and single-flight mutations. You can apply an optimistic update to a query while the mutation is in flight:

<script>
  import { addComment, getCommentCount } from '$lib/data.remote';
  
  let { postId } = $props();
  let count = $derived(await getCommentCount(postId));
  
  async function submit() {
    await addComment({ postId, content: '...' }).updates(
      getCommentCount(postId).withOverride((n) => n + 1)
    );
  }
</script>
 
<button onclick={submit}>Add Comment</button>
<span>Comments: {count}</span>

The count increments immediately when you click the button. If the mutation fails, SvelteKit reverts the override. If it succeeds, the server's updated count replaces the override, so the UI stays accurate.

This pattern works best for simple numeric updates like likes, votes, or counts. For complex state like lists or nested objects, optimistic updates get tricky fast. I usually skip them unless the UX gain is obvious.

Link to section: Measuring Real ImpactMeasuring Real Impact

I refactored a production dashboard that shows a table of orders with customer info, payment status, and shipping details. Before October, each row queried customer data separately—50 rows meant 50 queries.

After switching to query.batch:

  • Initial page load dropped from 2.1s to 850ms (59% faster)
  • Database query count went from 50 to 1
  • Server CPU usage during peak traffic decreased by 12%

I also added lazy discovery by removing unused functions from a third-party analytics SDK. Bundle size shrank from 156KB to 138KB, improving Largest Contentful Paint by 60ms on mobile.

The schema-powered forms cleaned up about 40 lines of validation boilerplate per form. Not a performance win, but it made the codebase easier to maintain.

Link to section: When to Stick with Load FunctionsWhen to Stick with Load Functions

Remote Functions aren't always the right tool. I still use load functions for:

  • Data that multiple routes share, like user sessions or global config
  • SEO-critical content where server rendering is mandatory
  • Pre-fetching on link hover via data-sveltekit-preload-data

Load functions run before the page renders, so you never see a loading state. Remote Functions fetch on demand, which means a flash of empty content if the network is slow.

For a marketing site or blog, I'd use load functions to guarantee content is in the initial HTML. For a dashboard or admin panel where SEO doesn't matter, Remote Functions let me co-locate data with components.

Link to section: Migration PathMigration Path

If you're already using Remote Functions, upgrading to 2.42.0 or higher is straightforward:

  1. Update SvelteKit: npm install @sveltejs/kit@latest
  2. Update Svelte: npm install svelte@latest
  3. Enable async SSR in svelte.config.js:
export default {
  compilerOptions: {
    experimental: {
      async: true
    }
  },
  kit: {
    experimental: {
      remoteFunctions: true
    }
  }
};

Then identify queries that fetch related data in loops. Wrap them in query.batch and provide a resolver function. Test with production data volumes to confirm the database handles batch queries efficiently.

For forms, replace manual error tracking with schema validation. Use invalid() to populate field-level errors, and spread {...form.fields.fieldName.as('type')} on inputs.

Link to section: Trade-offs and GotchasTrade-offs and Gotchas

Batching adds complexity in the resolver function. You must map inputs to outputs correctly, or queries resolve with undefined. I've debugged this twice by logging the lookup Map and confirming all IDs exist.

Lazy discovery only helps if you're importing third-party Remote Functions. If all your functions live in src/lib, it makes no difference.

Async SSR increases server response time for slow queries. If a query takes 3 seconds, the browser waits 3 seconds for HTML. Use streaming or defer non-critical data to client-side fetches.

Schema validation in forms requires Standard Schema-compatible libraries like Valibot or Zod. If you're using a custom validation layer, you'll need an adapter or stick with manual error handling.

Link to section: What's NextWhat's Next

The Svelte team hinted at stream as a future remote function type for server-sent events. That would pair nicely with async SSR for live-updating dashboards without WebSockets.

Caching strategies for Remote Functions are still manual. I'd like to see built-in TTL or stale-while-revalidate patterns, similar to SWR or React Query.

For now, I'm using these October updates in production and they've solved real bottlenecks. If you're building data-heavy SvelteKit apps, batching and schema-powered forms are worth the upgrade.