Skip to main content
node

The Backend Decision That Breaks Mobile UI

Large payloads, missing cursor pagination, absent image sizing hints, and synchronous endpoints — four backend choices that make mobile UIs fragile by design.


5 min read

Mobile UI problems are often blamed on the frontend. Slow renders, layout shift, janky scroll — engineers reach for virtualization, memoization, and skeleton screens. Sometimes those fixes are right. More often, the real problem is 30 floors up in the architecture, in a backend decision made without a mobile client in mind. Four patterns cause most of it.


Oversized Payloads on Slow Connections

A REST endpoint that returns the full user object — including embedded preferences, permission arrays, notification history, and audit fields — might be 14KB of JSON. On desktop over broadband, imperceptible. On a mobile device on a 4G connection with 200ms latency, that's a noticeable wait before anything renders.

The fix is not to gzip harder. It's field selection or response shaping at the API layer. GraphQL solves this naturally. REST can solve it with sparse fieldsets (jsonapi.org spec has the pattern) or with purpose-built endpoints per view.

The rule of thumb: a list endpoint response for 20 items should not exceed 50KB. If it does, you're sending the client data it isn't rendering. Profile your API responses. curl -w "%{size_download}" is enough to start.

No Cursor Pagination (Infinite Scroll Is Broken)

Offset-based pagination (?page=2&limit=20) is the default in most ORMs. It's also fundamentally broken for infinite scroll on mobile.

The problem: if a new item is inserted at the top of the dataset while a user is scrolling, every subsequent page=N call shifts by one. Items get skipped. Items get shown twice. Users see the same product listing twice or miss a message entirely.

Cursor pagination fixes this. The server returns an opaque cursor (usually an encoded timestamp + ID pair). The client sends ?after=<cursor> and gets the next stable slice regardless of insertions:

// Prisma cursor pagination — stable under concurrent writes
async function getItems(cursor?: string, limit = 20) {
  const items = await prisma.item.findMany({
    take: limit + 1,
    ...(cursor && {
      cursor: { id: cursor },
      skip: 1,
    }),
    orderBy: { createdAt: "desc" },
  });

  const hasMore = items.length > limit;
  const page = hasMore ? items.slice(0, limit) : items;
  const nextCursor = hasMore ? page[page.length - 1].id : null;

  return { items: page, nextCursor };
}

If your list API only speaks page numbers, you can't implement reliable infinite scroll. You can fake it, but users will eventually hit the duplication or skip bug.

Missing Image Sizing Hints

Layout shift on mobile is almost always an image problem. The browser doesn't know how tall an image is until it downloads the first bytes of the response. If the API returns image URLs without dimensions, every image in a feed causes the layout to reflow when it loads.

The backend fix: include width and height on every image object in the response. Not optional metadata — required fields. The frontend can then set explicit dimensions or aspect-ratio CSS before the image loads, reserving the space:

{
  "id": "img_01",
  "url": "https://cdn.example.com/photo.jpg",
  "width": 1200,
  "height": 800
}
<img
  src={image.url}
  width={image.width}
  height={image.height}
  style={{ aspectRatio: `${image.width} / ${image.height}` }}
  loading="lazy"
/>

Cumulative Layout Shift (CLS) is a Core Web Vital. It directly affects your search ranking. If your API doesn't return image dimensions, you're paying for it in ways your backend team can't see on their dashboards.

Synchronous Blocking Endpoints

An endpoint that does work synchronously — calling a third-party API, running a report query, generating a PDF — and makes the client wait for the response is a ticking clock on mobile. Median mobile-to-server round trips include cell radio wake time. A 4-second synchronous response regularly becomes a 6–8 second wait on a real device.

The pattern that fixes this is returning early with a job ID:

// Instead of blocking:
app.post("/reports/generate", async (req, res) => {
  const report = await generateReport(req.body); // ← blocks for 4s
  res.json(report);
});

// Return immediately, process async:
app.post("/reports/generate", async (req, res) => {
  const jobId = await queue.add("generate-report", req.body);
  res.status(202).json({ jobId });
});

The client polls /jobs/:jobId for status. The UI can show progress. The user doesn't stare at a loading state with no feedback while the radio burns battery.

The 202 Accepted status code exists specifically for this pattern. Use it.

The Underlying Issue

All four of these are backend decisions with no visible cost to the backend team. The oversized payload still returns 200. The offset pagination still paginates. The missing dimensions still serves images. The synchronous endpoint still eventually responds. The cost is paid entirely by mobile users, in latency and jank they blame on "the app."

The fix is for backend engineers to test their APIs with a throttled mobile connection at least once. Chrome DevTools has network throttling. networkQuality on macOS works for system-level throttling. Run the app on a real device on a real connection before shipping a new endpoint. The feedback is immediate and humbling.