Back to posts
Performance9 min read

From SDK to SSR: Performance Optimization Lessons Across Frameworks

Cris Ryan Tan

Cris Ryan Tan

Senior Software Engineer

TL;DR: The same loop works everywhere: instrument, measure, identify, optimize. I learned it building Rokt's web SDK and reused it on a slow Remix page at Lorikeet, where parallelizing independent queries and deferring slow ones behind skeleton UI took observed page load from about 2.2 seconds to about 700ms. One caveat worth knowing upfront: defer makes pages feel faster, it doesn't make queries faster.

Prologue: Building Performance Culture at Rokt

Before the Lorikeet work, the foundation: I was part of the team that built WSDK2 at Rokt, where we cut SDK load time by 30% and script size by 40% across thousands of client integrations. What made that work successful wasn't any single optimization. It was the methodology: instrument, measure, identify, optimize. That loop turned out to be framework-agnostic, and it's the through-line of this post, from third-party SDKs running in iframes to server-side rendered React applications.

Measure First

You cannot improve what you do not measure. Performance optimization without data is guesswork: you need concrete numbers to identify bottlenecks, validate improvements, and communicate impact. HAR files, Lighthouse, and the DevTools Performance tab all have their place, but the approach that paid off most for the Remix work was simple instrumentation in the code itself.

Performance Markers: The Foundation of Optimization

At Rokt, we instrumented WSDK2 with Date.now() markers throughout the initialization flow. Why Date.now() instead of performance.now()? Because we were measuring across cross-origin iframes, from both the client website and the Rokt SDK. Date.now() gives consistent timestamps across iframe boundaries, whereas performance.now() is relative to each browsing context's time origin. For the Remix loaders at Lorikeet, we switched to performance.now(): everything runs in a single Node.js process, so we get microsecond resolution and monotonic timing that's immune to system clock adjustments.

Key Insight: Choose Your Timer Wisely

  • Use Date.now() when measuring across different contexts (iframes, workers, multiple browser tabs)
  • Use performance.now() for high-precision measurements within a single JavaScript context
  • Use performance.mark() for integration with browser DevTools and the Performance API

Collecting Production Timing Data

Synthetic tests and local development don't capture real user conditions: network latency, device capabilities, cache states. So we shipped the markers to production. Here's what typical timing data looked like for a slow Remix route:

timing: {
  authDurationMs: "100-500ms",
  configQueryMs: "50-200ms",
  mainContentQueryMs: "1000-2500ms",  // ⚠️ Bottleneck!
  auxiliaryQueryMs: "20-200ms",
  totalLoaderMs: "1200-3000ms"
}

One query was eating 70-80% of total load time. That's the insight you need before touching any code: now you know exactly where the effort should go.

The Result at Lorikeet

The slowest page in our web application averaged around 2.2 seconds to load. After applying the three patterns below, observed page load, the time until users see meaningful content, came down to roughly 700ms:

Observed page load: 2.2s → ~700ms

68% faster to meaningful content

To be precise about what moved: parallelizing queries cut actual loader time, and deferring the slowest query behind skeleton UI cut the time users wait to see the page. The slow query itself still takes as long as it always did. More on that distinction below.

These aren't exotic tricks. They're patterns recommended by the Remix team and taught in depth by Kent C. Dodds in his Advanced Remix Frontend Masters course. Here's what worked.

Pattern 1: Parallel Query Execution

The first issue we found: queries that didn't depend on each other were running one after another. This is the most common performance anti-pattern in async JavaScript. When queries are independent, they should run in parallel, period.

Before (sequential - slow):

// ❌ configData blocks independentQuery unnecessarily
const configData = await fetchConfig(auth)

// This starts AFTER configData completes (bad!)
const independentQueryPromise = fetchIndependentData(auth)

const dependentQueryPromise = fetchDependentData(configData)

await Promise.all([independentQueryPromise, dependentQueryPromise])

After (parallel - fast):

// ✅ Start independent queries immediately
const independentQueryPromise = fetchIndependentData(auth)

// Only await config when actually needed
const configData = await fetchConfig(auth)

const dependentQueryPromise = fetchDependentData(configData)

await Promise.all([independentQueryPromise, dependentQueryPromise])

Key insight: Start independent queries before awaiting dependencies they don't need. This simple reordering can shave hundreds of milliseconds off your critical path.

The Same Bug in Loop Form

The sequential-await mistake shows up in loops too, and it's worth recognizing on sight. A for...of loop with an await inside runs every iteration back to back: five users at 100ms each is 500ms. The fix is Promise.all(items.map(...)), which fires all requests simultaneously and finishes when the slowest one does:

// ❌ Sequential: each iteration waits for the previous one
for (const userId of userIds) {
  results.push(await fetchUser(userId))
}
// 5 users × 100ms → 500ms total

// ✅ Parallel: all requests fire simultaneously
const results = await Promise.all(
  userIds.map(userId => fetchUser(userId))
)
// 5 users in parallel → 100ms total (limited by slowest)

Two related traps: chaining multiple Promise.all batches when the second batch doesn't actually depend on the first (merge them into one), and using forEach with an async callback, which doesn't await anything. Your array is still empty when the next line runs. Use Promise.all with map instead.

Pattern 2: Defer Non-Critical Data

Remix's defer() utility streams non-critical data after navigation, so users see content immediately while the rest loads in the background:

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const auth = await enforceProtectedRoute({ request })

  // Critical data: needed for page structure
  const filters = await fetchFilters(auth)
  const pagination = { page: 1, pageSize: 20 }

  // Non-critical data: defer these!
  const dropdownOptionsPromise = fetchDropdownOptions(auth)
  const sidebarDataPromise = fetchSidebarData(auth)

  return defer({
    // Synchronous: page renders immediately
    filters,
    pagination,

    // Deferred: streams in after navigation
    deferredDropdownOptions: dropdownOptionsPromise,
    deferredSidebarData: sidebarDataPromise,
  })
}

On the component side, use Suspense with Await to progressively hydrate deferred data:

<Suspense fallback={<SkeletonLoader />}>
  <Await
    resolve={deferredDropdownOptions}
    errorElement={<ErrorFallback />}
  >
    {(options) => <FilterDropdown options={options} />}
  </Await>
</Suspense>

Pattern 3: Defer Main Content with Skeleton UI

When a single query dominates your total load time, deferring secondary UI elements isn't enough. In that case, defer the main content itself and show a skeleton immediately. This was our biggest UX win: instead of staring at a spinner for seconds, users see the page structure instantly with content populating progressively.

return defer({
  // Synchronous: page shell renders instantly
  filters: currentFilters,
  pagination: { page, pageSize },

  // DEFERRED: Main content (slow query)
  deferredMainContent: fetchMainContent(filters),
})

Skeleton UI implementation:

<Suspense
  fallback={
    <div className="flex flex-col gap-4 pt-4">
      {[...Array(10)].map((_, i) => (
        <div className="flex items-center gap-4 py-2" key={i}>
          <div className="h-4 w-16 animate-pulse rounded bg-gray-200" />
          <div className="h-4 w-48 animate-pulse rounded bg-gray-200" />
          <div className="h-4 w-32 animate-pulse rounded bg-gray-200" />
        </div>
      ))}
    </div>
  }
>
  <Await resolve={deferredMainContent}>
    {(data) => <ContentTable data={data} />}
  </Await>
</Suspense>

Perceived vs Actual Performance

"Defer does not make your queries faster, it makes your application feel faster by rendering content progressively while data loads in the background."

This distinction matters. Deferring a slow query changes your First Contentful Paint, when users see meaningful content, but the query still takes exactly as long to execute. So track both: FCP and LCP for what users experience, raw query duration for backend health. Defer is a resilience tool, not a substitute for optimizing the slow query at its source. Fast queries plus progressive rendering is the goal. Defer alone gives you acceptable UX while masking technical debt.

What to Defer vs Await

Not everything should be deferred. Always await authentication, data that determines page structure, and anything other queries depend on. Good defer candidates are secondary elements: dropdown options, sidebars, tooltips, analytics. Defer main content only when one query dominates total load time and you can show a sensible skeleton in its place.

Before deferring anything, ask:

  1. Can users see meaningful content without this data?
  2. Is there a reasonable loading/skeleton state?
  3. Do other queries depend on this result?
  4. Is this query the primary bottleneck?

Important Limitations: Remix Defer Bug

Remix's defer() has a known issue (issue #6637) where it does not work correctly on same-route navigation with changed URL parameters:

ScenarioDefer Works?
Initial page load✅ Yes
Navigate to different route✅ Yes
Change filters/date on same route❌ No (waits for all data)

In practice this is livable: defer still helps on initial loads and cross-route navigation, and when it "fails" on same-route navigation it degrades gracefully to normal await behavior, no worse than before. And the biggest wins came from parallel query restructuring anyway, which works regardless of this bug.

Conclusion

Performance optimization is a systematic discipline, not magic. The methodology I learned building WSDK2 at Rokt, instrument, measure, identify, optimize, worked just as well on a server-side rendered React app. The stack changes; the loop doesn't. Start measuring today, identify your bottleneck, and apply the pattern that fits: parallelize what's independent, defer what's secondary, and skeleton what's slow.

From Personal Learning to Team Capability

After this work, I documented the whole methodology as a Claude Skill called remix-page-load-optimization. Now when teammates hit a slow page, Claude applies these same patterns, including the honest limitations about when defer doesn't help, without anyone needing to remember the details or dig through docs.

Want to learn how to turn your expertise into team capability?

I wrote a detailed guide on using Claude Skills to transform personal knowledge into institutional capability that works automatically for your entire team.

Read: Claude Skills - Turning Personal Expertise into Team Superpowers

Related Topics

PerformanceSSRReact

Enjoyed this article?

I write about web performance, AI-assisted development, and building things that scale. Let's connect.

© 2026 Cris Ryan Tan. All rights reserved.

Built with Gatsby, React, Tailwind CSS & Motion