JSON Mobile API Optimization: Payload Size, Compression & Offline-Friendly Design

Last updated:

Most JSON API guides focus on REST design principles — resource naming, HTTP verbs, status codes. Mobile JSON optimization is a different discipline: the bottleneck is not the API design but the physics of cellular networks. A 3G connection delivers under 1 MB/s with 100–300 ms latency; a new HTTPS connection adds another 200–500 ms for TCP and TLS handshake before the first byte of response arrives. This guide covers the concrete techniques that reduce those costs: sparse fieldsets that shrink payloads by 40–60%, Brotli compression that beats gzip by 20–26%, cursor pagination sized for mobile viewports, batch requests that collapse N round trips into one, offline-friendly delta sync that keeps apps functional without connectivity, and exponential backoff that survives flaky connections. Every section includes working code for Node.js/Express servers and iOS/Android clients.

Mobile JSON Payload Size Optimization

The single most impactful optimization for mobile JSON APIs is sending less data. A typical REST user profile endpoint returns 30–40 fields; a mobile list view renders 4–6. The unused fields cross the cellular network, get decompressed, are parsed by the JSON engine, and are then discarded. Sparse fieldsets, null stripping, and response shaping eliminate this waste before the bytes leave the server.

// ── Sparse fieldsets: ?fields=id,name,avatar ─────────────────────────
// Express middleware that strips fields not in the request parameter

import express from 'express'

function sparseFieldset(req, res, next) {
  if (!req.query.fields) return next()

  const allowed = new Set(req.query.fields.split(',').map(f => f.trim()))
  const originalJson = res.json.bind(res)

  res.json = (data) => {
    if (Array.isArray(data)) {
      originalJson(data.map(item => pickFields(item, allowed)))
    } else if (data && typeof data === 'object') {
      originalJson(pickFields(data, allowed))
    } else {
      originalJson(data)
    }
  }
  next()
}

function pickFields(obj, allowed) {
  return Object.fromEntries(
    Object.entries(obj).filter(([key]) => allowed.has(key))
  )
}

// Usage:
// GET /users/42?fields=id,name,avatar
// GET /users?fields=id,name,avatar&page=1
app.get('/users/:id', sparseFieldset, async (req, res) => {
  const user = await db.users.findById(req.params.id)
  res.json(user) // middleware intercepts and strips to allowed fields
})

// ── Full response (no ?fields): 1,840 bytes ──────────────────────────
{
  "id": "usr_42",
  "name": "Alice Chen",
  "avatar": "https://cdn.example.com/avatars/42.webp",
  "email": "alice@example.com",
  "phone": "+1-555-0100",
  "bio": "Product designer. Making things simpler.",
  "website": "https://alicechen.design",
  "location": "San Francisco, CA",
  "timezone": "America/Los_Angeles",
  "createdAt": "2023-04-15T09:23:11Z",
  "updatedAt": "2026-02-01T14:55:02Z",
  "lastLoginAt": "2026-02-19T08:00:00Z",
  "isVerified": true,
  "isPremium": false,
  "followerCount": 1204,
  "followingCount": 87,
  "postCount": 342,
  "preferences": { "theme": "dark", "notifications": true, "language": "en" },
  "socialLinks": { "twitter": "@alicechen", "github": "alicechen-ux" },
  "address": { "street": "...", "city": "San Francisco", "country": "US" }
}

// ── ?fields=id,name,avatar response: 148 bytes (92% smaller) ─────────
{
  "id": "usr_42",
  "name": "Alice Chen",
  "avatar": "https://cdn.example.com/avatars/42.webp"
}

// ── Null stripping: remove keys with null/empty values ────────────────
function stripNulls(obj) {
  return Object.fromEntries(
    Object.entries(obj).filter(([, v]) =>
      v !== null && v !== undefined && !(Array.isArray(v) && v.length === 0)
    )
  )
}

// Before null stripping: 420 bytes
{ "id": "usr_42", "name": "Alice", "phone": null,
  "bio": null, "website": null, "premiumSince": null,
  "tags": [], "badges": [] }

// After null stripping: 38 bytes
{ "id": "usr_42", "name": "Alice" }

// ── Payload budget targets ────────────────────────────────────────────
// List responses (20–50 items):  < 50 KB — renders in < 50ms on 3G
// Single resource:               < 20 KB — loads in < 20ms on 3G
// Critical path (above the fold): < 10 KB — prioritize for first render

// ── Database projection: don't SELECT columns you'll discard ─────────
// Bad: SELECT * FROM users (fetches 40 columns)
// Good: translate fields param to a SQL projection
async function getUserFields(id, fields) {
  const safeFields = [...fields].filter(f => ALLOWED_COLUMNS.has(f))
  const cols = safeFields.join(', ') || 'id, name'  // fallback
  return db.query(`SELECT ${cols} FROM users WHERE id = $1`, [id])
}

Translate the fields parameter all the way down to the database SELECT projection — if you fetch 40 columns and then strip 36, you paid network I/O between the app server and the database for nothing. Validate the field whitelist against known column names before interpolating into SQL. For general JSON API design patterns including versioning and content negotiation, see the linked guide.

Compression for Mobile JSON

Brotli and gzip both reduce the wire size of JSON dramatically — JSON is highly repetitive text with predictable structure, which compresses extremely well. Brotli beats gzip by 20–26% for JSON payloads because its predefined dictionary includes common web tokens including JSON syntax characters, HTTP header names, and English words. The decompression overhead on mobile CPUs is negligible compared to the bandwidth saved on a slow cellular connection.

// ── Express: enable Brotli + gzip with shrink-ray-current ────────────
import express from 'express'
import shrinkRay from 'shrink-ray-current'

const app = express()

app.use(shrinkRay({
  brotli: { quality: 5 },   // level 1-11; 4-6 is sweet spot for APIs
  zlib:   { level: 6 },     // gzip fallback
  filter: (req, res) => {
    // Skip compression for responses < 1 KB (overhead not worth it)
    const len = parseInt(res.getHeader('Content-Length') || '0', 10)
    if (len > 0 && len < 1024) return false
    return shrinkRay.filter(req, res)
  },
  threshold: 1024,           // don't compress bodies under 1 KB
}))

// ── nginx: enable Brotli (requires ngx_brotli module) ────────────────
// /etc/nginx/nginx.conf
//
// brotli              on;
// brotli_comp_level   5;
// brotli_types        application/json text/plain application/javascript;
// brotli_min_length   1024;
//
// gzip                on;
// gzip_comp_level     6;
// gzip_types          application/json text/plain;
// gzip_min_length     1024;

// ── Content negotiation: client declares what it accepts ──────────────
// iOS NSURLSession sends automatically:
//   Accept-Encoding: br, gzip, deflate
// Android OkHttp 3.13+ sends automatically:
//   Accept-Encoding: gzip, br

// Server responds with:
//   Content-Encoding: br        (Brotli chosen)
//   Vary: Accept-Encoding       (tells CDN to cache per encoding)

// ── Compression benchmark: 50-item user list (raw JSON) ──────────────
// Uncompressed:    48,240 bytes  (47.1 KB)
// gzip  level 6:  11,820 bytes  (11.5 KB)  — 75.5% reduction
// brotli level 5:  9,260 bytes   (9.0 KB)  — 80.8% reduction
// brotli is 21.7% smaller than gzip on this payload

// ── Verify compression with curl ──────────────────────────────────────
// curl -H "Accept-Encoding: br" -I https://api.example.com/users
// HTTP/2 200
// content-type: application/json
// content-encoding: br
// vary: Accept-Encoding

// ── iOS: verify OkHttp-style decompression (Swift) ────────────────────
// URLSession handles decompression automatically — no extra code needed
var request = URLRequest(url: URL(string: "https://api.example.com/users")!)
request.setValue("br, gzip, deflate", forHTTPHeaderField: "Accept-Encoding")
// Response body is already decompressed by URLSession before your handler sees it

// ── Android: OkHttp automatic decompression ───────────────────────────
// OkHttp adds Accept-Encoding: gzip, br automatically
// No manual decompression needed — response body is transparently inflated
val client = OkHttpClient.Builder()
    .addInterceptor { chain ->
        val request = chain.request().newBuilder()
            .addHeader("Accept-Encoding", "br, gzip")
            .build()
        chain.proceed(request)
    }
    .build()

Always set Vary: Accept-Encodingon compressed responses so CDN caches store separate copies per encoding and serve the correct one to each client. Brotli at quality level 5 offers a good balance of compression ratio and CPU time — level 11 adds only ~2% more compression at 10× the CPU cost. For a 10 KB JSON response at level 5, compression takes under 0.5 ms on a modern server CPU.

Offline-Friendly JSON API Design

Offline-friendly JSON APIs are designed around the assumption that the client will lose connectivity, store data locally, continue operating, and then resync. The four pillars are: stable permanent IDs for cache keying, idempotent mutations for safe retry, delta sync responses for efficient reconnection, and conditional refresh headers for bandwidth-free freshness checks.

// ── 1. Stable IDs: UUIDs or opaque strings, never positional ─────────
// Bad:  { "id": 1 }   — changes if rows are deleted and re-inserted
// Good: { "id": "usr_h7k2m9p4" }  — permanent, globally unique

// ── 2. Idempotent mutations: client-generated idempotency key ─────────
// Client generates a UUID per operation and sends it in the header.
// Server stores (idempotencyKey, userId) and returns cached response
// if the same key arrives again — safe to retry on timeout or error.

// POST /orders
// Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
{
  "items": [{ "productId": "prod_99", "qty": 2 }],
  "total": 49.98
}

// Server handler (Express):
app.post('/orders', async (req, res) => {
  const key = req.headers['idempotency-key']
  if (key) {
    const cached = await cache.get(`idem:${key}:${req.user.id}`)
    if (cached) return res.status(cached.status).json(cached.body)
  }
  const order = await db.orders.create(req.body)
  const response = { id: order.id, status: 'created', ...order }
  if (key) {
    await cache.set(`idem:${key}:${req.user.id}`, { status: 201, body: response }, 86400)
  }
  res.status(201).json(response)
})

// ── 3. Delta sync: return only changed items since last sync ──────────
// List response includes a syncToken the client stores locally
{
  "items": [...],
  "syncToken": "eyJsYXN0U3luY0F0IjoiMjAyNi0wMi0xOVQxMDowMDowMFoiLCJsYXN0SWQiOiJ1c3JfNDIifQ==",
  "hasMore": false
}

// On reconnect, client sends the stored syncToken:
// GET /items?since=eyJsYXN0U3luY0F0Ijo...
// Server decodes it: { lastSyncAt: "2026-02-19T10:00:00Z", lastId: "usr_42" }

app.get('/items', async (req, res) => {
  let items, deletions = []
  if (req.query.since) {
    const cursor = JSON.parse(Buffer.from(req.query.since, 'base64').toString())
    items = await db.items.findModifiedSince(cursor.lastSyncAt, req.user.id)
    deletions = await db.deletedItems.findSince(cursor.lastSyncAt, req.user.id)
  } else {
    items = await db.items.findAll(req.user.id)
  }
  const now = new Date().toISOString()
  const newToken = Buffer.from(JSON.stringify({ lastSyncAt: now })).toString('base64')
  res.json({ items, deletions, syncToken: newToken, hasMore: false })
})

// Delta response on reconnect after 2 hours offline:
{
  "items": [
    { "id": "item_55", "name": "Updated Item", "updatedAt": "2026-02-19T11:30:00Z" }
  ],
  "deletions": [
    { "id": "item_43", "deletedAt": "2026-02-19T10:15:00Z" }
  ],
  "syncToken": "eyJsYXN0U3luY0F0IjoiMjAyNi0wMi0xOVQxMjowMDowMFoifQ==",
  "hasMore": false
}

// ── 4. Conditional refresh: 304 Not Modified ─────────────────────────
// Initial response:
// Last-Modified: Wed, 19 Feb 2026 10:00:00 GMT
// ETag: "abc123def456"

// Subsequent request (client sends cached headers):
// If-Modified-Since: Wed, 19 Feb 2026 10:00:00 GMT
// If-None-Match: "abc123def456"

// Server returns 304 with no body if data is unchanged — zero download cost
app.get('/profile', async (req, res) => {
  const profile = await db.profiles.findById(req.user.id)
  const etag = `"${profile.version}"`
  if (req.headers['if-none-match'] === etag) {
    return res.status(304).end()
  }
  res.set('ETag', etag)
  res.set('Last-Modified', profile.updatedAt.toUTCString())
  res.json(profile)
})

Store the syncToken in local SQLite (iOS Core Data / Android Room) alongside the cached items. On app launch, render from cache immediately and trigger a background delta sync — users see content in under 100 ms regardless of network state. For JSON caching strategies including HTTP cache headers and CDN configuration, see the linked guide.

Mobile Batch Requests for JSON APIs

On a cellular connection, establishing a new HTTPS connection takes 200–500 ms for TCP handshake plus TLS 1.3 negotiation — before a single byte of API response arrives. A home screen that calls 6 separate endpoints sequentially adds 1.2–3 seconds of connection overhead alone. Batch requests collapse those round trips into one, paying the connection cost once.

// ── Server: POST /batch endpoint ─────────────────────────────────────
// Accepts an array of sub-requests, returns an array of sub-responses

app.post('/batch', async (req, res) => {
  const requests = req.body.requests  // array of { method, path, body }
  const MAX_BATCH = 10
  if (!Array.isArray(requests) || requests.length > MAX_BATCH) {
    return res.status(400).json({ error: 'Max 10 requests per batch' })
  }

  const results = await Promise.all(
    requests.map(async (subreq) => {
      try {
        const data = await routeSubRequest(subreq.method, subreq.path, subreq.body, req.user)
        return { status: 200, body: data }
      } catch (err) {
        return { status: err.status || 500, body: { error: err.message } }
      }
    })
  )
  res.json({ responses: results })
})

// Client request: single POST replaces 4 separate API calls
// POST /batch
{
  "requests": [
    { "method": "GET", "path": "/profile" },
    { "method": "GET", "path": "/notifications/count" },
    { "method": "GET", "path": "/feed?limit=20" },
    { "method": "GET", "path": "/featured" }
  ]
}

// Batch response: all 4 results in one round trip
{
  "responses": [
    { "status": 200, "body": { "id": "usr_42", "name": "Alice", ... } },
    { "status": 200, "body": { "unread": 3 } },
    { "status": 200, "body": { "items": [...], "nextCursor": "..." } },
    { "status": 200, "body": { "featured": [...] } }
  ]
}

// ── Client: automatic coalescing (accumulate calls in 50ms window) ────
class BatchClient {
  queue = []
  timer = null

  async get(path) {
    return new Promise((resolve, reject) => {
      this.queue.push({ method: 'GET', path, resolve, reject })
      if (!this.timer) {
        this.timer = setTimeout(() => this.flush(), 50)
      }
    })
  }

  async flush() {
    this.timer = null
    const batch = this.queue.splice(0)
    try {
      const res = await fetch('/batch', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ requests: batch.map(({ method, path }) => ({ method, path })) }),
      })
      const { responses } = await res.json()
      responses.forEach((resp, i) => {
        if (resp.status >= 400) batch[i].reject(new Error(resp.body.error))
        else batch[i].resolve(resp.body)
      })
    } catch (err) {
      batch.forEach(r => r.reject(err))
    }
  }
}

// Usage — all four calls go out in a single batch POST:
const client = new BatchClient()
const [profile, notifs, feed, featured] = await Promise.all([
  client.get('/profile'),
  client.get('/notifications/count'),
  client.get('/feed?limit=20'),
  client.get('/featured'),
])

// ── BFF pattern: composite endpoint for a specific screen ─────────────
// GET /screens/home  — returns everything the home screen needs
app.get('/screens/home', async (req, res) => {
  const [profile, notifCount, feed, featured] = await Promise.all([
    db.profiles.findById(req.user.id),
    db.notifications.countUnread(req.user.id),
    db.feed.findRecent(req.user.id, 20),
    db.featured.findActive(),
  ])
  res.json({ profile, notifCount, feed, featured })
})

Limit batch size to 10–20 sub-requests to prevent runaway memory use and long-tail latency (the batch response is delayed by the slowest sub-request). Run sub-requests in parallel on the server with Promise.all(), not sequentially. The BFF (Backend for Frontend) pattern is the simplest form of batching — a composite endpoint is just a pre-defined batch, versioned per screen type.

JSON Pagination for Mobile

Pagination strategy has a direct impact on perceived mobile app performance. Page-number pagination (?page=3&per_page=20) is simple but breaks when items are inserted or deleted between pages, causing duplicates or gaps. Cursor-based pagination is stable and works natively with infinite scroll — the most common mobile feed pattern.

// ── Cursor-based pagination: stable and infinite-scroll friendly ──────

// Response format
{
  "items": [
    { "id": "post_991", "title": "...", "createdAt": "2026-02-19T12:00:00Z" },
    { "id": "post_990", "title": "...", "createdAt": "2026-02-19T11:58:00Z" }
    // ... 18 more items
  ],
  "pagination": {
    "nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTAyLTE3VDA5OjAwOjAwWiIsImlkIjoicG9zdF85NzEifQ==",
    "prevCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTAyLTE5VDEyOjAwOjAwWiIsImlkIjoicG9zdF85OTEifQ==",
    "hasNext": true,
    "hasPrev": false
  }
}

// Server implementation (Express + PostgreSQL)
app.get('/feed', async (req, res) => {
  const limit = Math.min(parseInt(req.query.limit || '20', 10), 50)
  let cursor = null

  if (req.query.cursor) {
    cursor = JSON.parse(Buffer.from(req.query.cursor, 'base64url').toString())
    // cursor = { createdAt: "2026-02-17T09:00:00Z", id: "post_971" }
  }

  const whereClause = cursor
    ? `WHERE (created_at, id) < ($1, $2)`  // keyset pagination
    : ''
  const params = cursor ? [cursor.createdAt, cursor.id] : []

  // Fetch limit + 1 to detect if there is a next page
  const rows = await db.query(`
    SELECT id, title, created_at
    FROM posts
    WHERE user_feed_id = $${params.length + 1}
    ${whereClause ? 'AND (created_at, id) < ($1, $2)' : ''}
    ORDER BY created_at DESC, id DESC
    LIMIT $${params.length + 2}
  `, [...params, req.user.id, limit + 1])

  const hasNext = rows.length > limit
  const items = hasNext ? rows.slice(0, limit) : rows

  const lastItem = items[items.length - 1]
  const nextCursor = lastItem
    ? Buffer.from(JSON.stringify({ createdAt: lastItem.created_at, id: lastItem.id })).toString('base64url')
    : null

  res.json({
    items,
    pagination: { nextCursor, hasNext, hasPrev: !!cursor },
  })
})

// ── Page size recommendations for mobile ─────────────────────────────
// Feed/timeline:  20 items (renders in one viewport + a few below fold)
// Search results: 20 items (user rarely scrolls past first page)
// Image grids:    30 items (3-column grid shows 10 rows)
// Text-only lists: 50 items (low per-item byte cost)
// Max recommended: 50 items per page — beyond this, JSON parse time on
// older Android devices (< 2 GB RAM) causes jank

// ── Prefetch next page before user reaches the bottom ────────────────
// React Native / Swift pseudocode:
// When user scrolls past 80% of current list, trigger next page fetch

function handleScroll(scrollPosition, contentHeight, pageSize) {
  const threshold = 0.8  // prefetch at 80% scroll depth
  if (scrollPosition / contentHeight > threshold && !isFetching && hasNextPage) {
    fetchNextPage(nextCursor)  // begins loading before user hits bottom
  }
}

// ── iOS: infinite scroll with UICollectionView prefetching ────────────
// UICollectionViewDataSourcePrefetching protocol:
// func collectionView(_ collectionView, prefetchItemsAt indexPaths) {
//   if indexPaths.contains(where: { $0.row > items.count - 5 }) {
//     loadNextPage()
//   }
// }

// ── Android: Paging 3 library with cursor support ─────────────────────
// class FeedPagingSource : PagingSource<String, Post>() {
//   override suspend fun load(params: LoadParams<String>): LoadResult<String, Post> {
//     val cursor = params.key
//     val response = api.getFeed(cursor = cursor, limit = params.loadSize)
//     return LoadResult.Page(
//       data = response.items,
//       prevKey = response.pagination.prevCursor,
//       nextKey = response.pagination.nextCursor
//     )
//   }
// }

The keyset pagination SQL clause (created_at, id) < ($1, $2) is stable under concurrent inserts and deletions — it never shows a gap or duplicate regardless of writes happening between page fetches. Add a composite index on (user_feed_id, created_at DESC, id DESC) so the query uses an index scan with no sort step. For content that changes frequently, include the syncToken delta sync mechanism described in Section 3 to handle items that change between page loads.

Image and Binary Data in Mobile JSON APIs

Images are the largest assets in most mobile app JSON responses. The right strategy for mobile is URL references with multiple size variants, never base64-encoded binary data. Base64 inflates image size by 33% before transmission and requires the client to decode the string back to binary — wasting both bandwidth and CPU.

// ── URLs with size variants: the correct mobile approach ─────────────
{
  "id": "post_991",
  "title": "Meeting notes",
  "author": {
    "id": "usr_42",
    "name": "Alice",
    "avatar": {
      "thumb":  "https://cdn.example.com/avatars/42_48x48.webp",
      "small":  "https://cdn.example.com/avatars/42_96x96.webp",
      "medium": "https://cdn.example.com/avatars/42_192x192.webp"
    }
  },
  "coverImage": {
    "thumb":       "https://cdn.example.com/posts/991_320x180.webp",
    "medium":      "https://cdn.example.com/posts/991_640x360.webp",
    "full":        "https://cdn.example.com/posts/991_1280x720.webp",
    "placeholder": "data:image/jpeg;base64,/9j/4AAQ..."
  }
}
// placeholder is a 16x9 pixel JPEG (< 200 bytes base64) used as a
// blur-up placeholder while the full image loads — the only valid
// use case for base64 in mobile JSON (tiny blurred thumbnails only)

// ── WebP URLs: 25-35% smaller than JPEG for photos ───────────────────
// Serve WebP by default; add .jpg fallback URL for legacy clients
{
  "image": {
    "webp": "https://cdn.example.com/photos/42.webp",
    "jpeg": "https://cdn.example.com/photos/42.jpg",
    "width": 640,
    "height": 480,
    "blurHash": "LEHV6nWB2yk8pyo0adR*.7kCMdnj"
  }
}

// ── Why NOT base64 for images ─────────────────────────────────────────
// 100 KB image as base64:
//   - Size inflation: 100 KB * 1.33 = 133 KB (33% larger)
//   - Encoding CPU: server spends time base64-encoding binary
//   - Decoding CPU: client decodes base64 back to binary before display
//   - No browser cache: base64 blobs cannot be cached by the HTTP cache
//   - No CDN: cannot serve from a CDN edge node
//   - No progressive loading: entire JSON must arrive before image renders
// 100 KB image as URL:
//   - JSON field: ~80 bytes (URL string)
//   - Image download: concurrent with other images, from CDN edge
//   - Cached by URLCache / OkHttp cache by URL key
//   - Loaded progressively by the image framework (SDWebImage, Glide)

// ── Thumbnail strategy per screen type ───────────────────────────────
// Screen               Avatar size  Cover image    Full image
// Feed list            48x48        320x180        —
// Profile page         192x192      640x360        —
// Detail view          96x96        640x360        1280x720
// Chat list            40x40        —              —

// ── iOS: async image loading with URLSession ──────────────────────────
// SwiftUI AsyncImage handles URL loading and caching automatically:
// AsyncImage(url: URL(string: post.coverImage.thumb)) { phase in
//   switch phase {
//   case .success(let image): image.resizable().aspectRatio(contentMode: .fill)
//   case .failure:             Image(systemName: "photo")
//   case .empty:               ProgressView()
//   @unknown default:          EmptyView()
//   }
// }

// ── Android: Glide or Coil from URL in JSON ───────────────────────────
// Glide.with(context).load(post.coverImage.thumb).into(imageView)
// Coil: AsyncImage(model = post.coverImage.thumb, contentDescription = "cover")

Always include width and height in your image JSON fields — this allows the client to reserve space in the layout before the image loads, preventing layout shift (CLS). BlurHash is a 20–30 character string encoding of a blurred preview generated server-side; it renders as a colored blur placeholder with no additional network request, improving perceived loading speed dramatically.

JSON API Error Handling for Mobile

Mobile networks are inherently unreliable — a user on the subway loses connectivity mid-request; someone in a rural area has 500 ms latency and 10% packet loss. Mobile JSON error handling must account for network failures, timeouts, temporary server errors, and offline state, and must distinguish between errors the app should retry automatically and errors the user needs to act on.

// ── Structured error response format ─────────────────────────────────
// Always return a consistent JSON error body regardless of status code
{
  "error": {
    "code":      "SYNC_CONFLICT",       // machine-readable, stable string
    "message":   "Item was modified by another device since last sync.",
    "retryable": false,                 // client should NOT auto-retry
    "retryAfter": null,                 // seconds to wait before retry (null = no guidance)
    "details": {
      "conflictingItemId": "item_55",
      "serverVersion": 7,
      "clientVersion": 5
    }
  }
}

// Retryable error (server overloaded — retry with backoff):
{
  "error": {
    "code":       "SERVER_OVERLOADED",
    "message":    "Service is temporarily unavailable. Please try again.",
    "retryable":  true,
    "retryAfter": 5
  }
}

// ── Exponential backoff with jitter ──────────────────────────────────
async function fetchWithBackoff(url, options = {}, maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      const controller = new AbortController()
      const timeout = setTimeout(() => controller.abort(), 10_000) // 10s timeout
      const res = await fetch(url, { ...options, signal: controller.signal })
      clearTimeout(timeout)

      if (res.ok) return res.json()

      const body = await res.json().catch(() => ({}))

      // Do not retry on client errors (4xx) except 429
      if (res.status >= 400 && res.status < 500 && res.status !== 429) {
        throw Object.assign(new Error(body.error?.message || 'Client error'), { status: res.status, body })
      }

      // Respect Retry-After header for 429 and 503
      const retryAfter = parseInt(res.headers.get('Retry-After') || body.error?.retryAfter || '0', 10)
      if (attempt < maxAttempts) {
        const backoff = Math.min(Math.pow(2, attempt - 1) * 1000, 30_000)
        const jitter  = Math.random() * 1000                    // 0–1s random jitter
        const wait    = Math.max(retryAfter * 1000, backoff + jitter)
        await new Promise(resolve => setTimeout(resolve, wait))
      }
    } catch (err) {
      if (err.name === 'AbortError') {
        // Timeout — retryable
        if (attempt < maxAttempts) continue
        throw new Error('Request timed out after ' + maxAttempts + ' attempts')
      }
      if (err.status) throw err  // Non-retryable 4xx — propagate immediately
      // Network error (offline, DNS failure) — retry with backoff
      if (attempt < maxAttempts) {
        const backoff = Math.min(Math.pow(2, attempt - 1) * 1000, 30_000)
        await new Promise(resolve => setTimeout(resolve, backoff + Math.random() * 1000))
      } else {
        throw err
      }
    }
  }
}

// Retry schedule (attempt -> wait before next):
// attempt 1: immediate
// attempt 2: 1s + jitter
// attempt 3: 2s + jitter
// attempt 4: 4s + jitter
// attempt 5: 8s + jitter (capped at 30s)

// ── Offline detection and request queuing ────────────────────────────
// Service Worker / React Native NetInfo:
import NetInfo from '@react-native-community/netinfo'
import AsyncStorage from '@react-native-async-storage/async-storage'

const offlineQueue = []

NetInfo.addEventListener(state => {
  if (state.isConnected && offlineQueue.length > 0) {
    flushOfflineQueue()
  }
})

async function mutateWithOfflineSupport(method, path, body) {
  const isOnline = (await NetInfo.fetch()).isConnected
  if (!isOnline) {
    // Queue for later — include idempotency key so replay is safe
    const queuedOp = { method, path, body, idempotencyKey: crypto.randomUUID(), queuedAt: Date.now() }
    offlineQueue.push(queuedOp)
    await AsyncStorage.setItem('offlineQueue', JSON.stringify(offlineQueue))
    return { queued: true }  // optimistic: update local state immediately
  }
  return fetchWithBackoff(path, { method, body: JSON.stringify(body) })
}

async function flushOfflineQueue() {
  const ops = [...offlineQueue]
  offlineQueue.length = 0
  for (const op of ops) {
    await fetchWithBackoff(op.path, {
      method: op.method,
      headers: { 'Idempotency-Key': op.idempotencyKey },
      body: JSON.stringify(op.body),
    }).catch(err => console.warn('Failed to replay queued op', op.path, err))
  }
  await AsyncStorage.removeItem('offlineQueue')
}

Use the retryable flag in error responses to guide automatic retry logic — the server knows whether the operation is safe to retry. For write operations, always include an Idempotency-Key header so replayed requests from the offline queue do not create duplicate records. iOS URLSessionConfiguration.waitsForConnectivity = true handles the "not yet connected" case automatically — the OS holds the request until connectivity is available and retries without app code involvement. For safe JSON parsing in TypeScript including error boundary patterns, see the linked guide.

Key Terms

Sparse Fieldsets
An API technique where the client specifies which fields it wants in the response via a query parameter, typically ?fields=id,name,avatar. The server omits all other fields before serializing the JSON response. Named after the JSON:API specification's sparse fieldsets feature. Reduces payload size by 40–60% for list endpoints where clients need only a subset of a resource's properties. Requires server-side field whitelisting to prevent information disclosure. Can be extended to database projections (SELECT id, name, avatar FROM users) to avoid fetching unnecessary data from the database.
Brotli Compression
A lossless data compression algorithm developed by Google and standardized in RFC 7932. Uses a predefined static dictionary of 13,000+ common web content patterns — including JSON syntax characters, HTTP headers, and English words — giving it a 20–26% advantage over gzip on typical JSON payloads. Announced as Content-Encoding: br in HTTP headers. Supported by all major browsers and mobile HTTP clients (iOS NSURLSession, Android OkHttp) since 2018. Available at compression levels 0–11; level 5–6 is recommended for API responses as a balance of ratio and CPU cost.
Delta Sync
A synchronization strategy where the server returns only the data that has changed since the client's last successful sync, rather than the full dataset. Implemented via a syncToken or since cursor that the client stores locally and sends on reconnection. The server uses this token to query records modified after that point and returns both updated items and a list of deleted item IDs. Dramatically reduces bandwidth on reconnection — a user who was offline for two hours downloads only the changes from those two hours, not the entire dataset. Requires soft deletion (marking records as deleted rather than removing rows) to include deletions in the delta response.
Batch Request
A single HTTP request that contains multiple logical API sub-requests, processed by the server and returned as an array of sub-responses. Eliminates repeated TCP handshake and TLS negotiation overhead — on a cellular connection, each new connection costs 200–500 ms before data transfer begins. A batch endpoint typically accepts a POST request with a JSON array of { method, path, body } objects and returns a JSON array of { status, body } results. The Backend for Frontend (BFF) pattern is a specialized form of batching where a composite endpoint is pre-defined for a specific screen rather than accepting arbitrary sub-requests.
Exponential Backoff
A retry strategy where the wait time between consecutive retry attempts grows exponentially — typically doubling after each failure. Common formula: wait = min(base * 2^attempt, maxWait). On the first failure the client retries immediately or after a short base delay (500 ms–1 s); subsequent failures wait 1 s, 2 s, 4 s, 8 s, up to a maximum (commonly 30–60 s). Jitter (adding a random 0–1 s offset to each wait) prevents all clients from retrying simultaneously after a server outage — the "thundering herd" problem. Used for both network errors and HTTP 429 (rate limited) and 503 (service unavailable) responses. Should not be applied to 4xx client errors (except 429) as they will not resolve on retry.
Prefetch
Loading the next page of data before the user explicitly requests it, based on scroll position or predicted navigation. In mobile feed-style apps, prefetching is triggered when the user has scrolled to 70–80% of the current page, initiating a background request for the next cursor's data. The result is stored in memory so the next page renders immediately with no visible loading state. iOS UICollectionViewDataSourcePrefetching and Android Paging 3 provide native framework support for list prefetching. Prefetch requests should be cancellable — if the user navigates away, cancel the in-flight prefetch to avoid wasting bandwidth and battery.
Payload Budget
A defined maximum size for a JSON API response, based on the target network conditions and time-to-interactive goals. Recommended mobile payload budgets: list responses under 50 KB (renders in under 50 ms on a 3G connection at 1 MB/s), single resource responses under 20 KB, above-the-fold critical path data under 10 KB. Establishing a payload budget as part of API design forces early decisions about sparse fieldsets, pagination size, and what data to lazy-load rather than include in the initial response. Measure payload sizes with real device network throttling (3G simulation in browser devtools or Android network throttling) rather than assuming fast local network conditions.
Cellular RTT (Round-Trip Time)
The time for a network packet to travel from the mobile device to the server and back. On 4G LTE, RTT is typically 30–80 ms. On 3G, 100–300 ms. On 2G, 300–1000 ms. For HTTPS, a new connection requires one RTT for TCP handshake plus one RTT for TLS 1.3 negotiation (two RTTs total) before the client can send the first HTTP request — adding 200–500 ms of pure overhead before any application data flows. HTTP/2 and HTTP/3 reuse connections across requests, eliminating this overhead for subsequent requests on the same connection. Mobile apps should use persistent HTTP/2 connections and batch requests to minimize the number of new connections established per session.

FAQ

How do I reduce JSON payload size for mobile API responses?

The most effective techniques are sparse fieldsets, null stripping, and response shaping. Sparse fieldsets accept a ?fields=id,name,avatar query parameter and omit unrequested fields before serialization — a typical user profile with 30–40 fields trimmed to 4–6 for a list view shrinks by 40–60%. Null stripping removes keys with null, undefined, or empty-array values that convey no information. Response shaping flattens unnecessary nesting and uses compact enum values. Translate the field list to a database SELECT projection so unused columns are not fetched at all. Target under 50 KB for list responses and under 20 KB for single-resource responses to stay under 50 ms render time on a 3G connection. Always measure payload sizes under realistic throttled network conditions — fast local Wi-Fi hides problems that manifest on cellular. Combine sparse fieldsets with Brotli compression for maximum reduction: the two techniques compound each other because a smaller JSON payload also compresses better.

Should I use gzip or Brotli compression for mobile JSON APIs?

Use Brotli (Content-Encoding: br). Brotli achieves 20–26% better compression than gzip on JSON payloads due to its predefined web content dictionary. Both iOS NSURLSession and Android OkHttp automatically send Accept-Encoding: br, gzip and transparently decompress whichever encoding the server chooses — no client code changes needed. Enable Brotli in Express with shrink-ray-current or express-static-gzip, and in nginx with the ngx_brotli module (brotli on; brotli_comp_level 5;). Use compression level 4–6 for API responses; higher levels give diminishing returns with significantly higher CPU cost. Always serve gzip as a fallback for the Vary: Accept-Encoding negotiation. Skip compression entirely for responses under 1 KB — the compressed output can be larger than the original due to Brotli's block overhead, and CPU cost is not recovered on such small payloads. Verify your configuration with curl -H "Accept-Encoding: br" -I https://your-api.com/endpoint and confirm content-encoding: br appears in the response headers.

How do I design a JSON API that works well when mobile users are offline?

Four design pillars make a JSON API offline-friendly. First, use stable permanent IDs (UUIDs, not sequential integers) on every resource so the mobile app can use them as persistent cache keys across sync cycles. Second, make all write operations idempotent by accepting a client-generated Idempotency-Key header — the server stores the response for 24 hours and returns it on replay, so the client can safely retry queued operations on reconnection without creating duplicates. Third, implement delta sync: include a syncToken in list responses and accept a ?since=token parameter that returns only items changed, added, or deleted since the last sync. Fourth, support conditional refresh with ETag and Last-Modified headers — a 304 Not Modified response with no body lets the client confirm data is still fresh without downloading it again. Store responses in local SQLite or IndexedDB, render immediately from cache on app launch, and trigger background delta sync — users see content instantly regardless of network state.

How do I implement field selection (sparse fieldsets) in a JSON API?

Accept a ?fields= query parameter with a comma-separated list of field names. Parse it into a Set of allowed fields. Use JSON.stringify(obj, [...allowedFields]) — the second argument is a replacer array that natively omits any key not in the list. For nested field selection, use dot-notation paths (fields=id,address.city) and implement a recursive pick function. Validate the field list against a whitelist of known field names before processing — reject unknown fields with a 400 error to prevent inadvertent information disclosure. Make the full response the default behavior so existing API clients without the ?fields= parameter are unaffected. Translate the field selection down to the database SELECT clause to avoid fetching unused columns. Include the fields parameter value in your cache key (or normalize field order before caching) so field-specific responses are cached separately. Document the available fields and their types — mobile teams need to know what to request.

What is the ideal JSON pagination strategy for mobile feed-style apps?

Use cursor-based (keyset) pagination, not page-number or offset-based pagination. Cursor pagination encodes the sort position of the last returned item as an opaque token (base64-encoded JSON) in the response's nextCursor field. The client sends this token as a query parameter on the next request. The server decodes it and queries WHERE (created_at, id) < (cursor_time, cursor_id) ORDER BY created_at DESC, id DESC — stable under concurrent inserts and deletions with no duplicate or gap risk. Use page sizes of 20–50 items: 20 for feed-style views (one screen plus a few items below the fold), up to 50 for text-only lists. Prefetch the next page when the user scrolls to 80% of the current content — iOS UICollectionViewDataSourcePrefetching and Android Paging 3 provide native framework support for this. Include a hasMore: false field to signal end of list. Return null for nextCursor when there are no more items. Add a composite index on (feed_id, created_at DESC, id DESC) for the keyset query to use an index scan without a sort step.

How do I handle JSON API errors gracefully when a mobile device has a flaky connection?

Use a combination of exponential backoff, idempotent retries, machine-readable error codes, and offline queuing. Exponential backoff: retry after 1 s, 2 s, 4 s, 8 s up to 30 s, with a random jitter of 0–1 s per attempt to prevent thundering herd. Retry on network errors, timeouts, HTTP 429 (rate limited), and HTTP 503 (service unavailable). Do not retry on 4xx client errors — they will not resolve on retry. Return a retryable boolean in every JSON error body so the client knows the server's intent. Set a 10-second request timeout on mobile — fail fast and retry rather than waiting indefinitely. Detect offline state with iOS Reachability or Android ConnectivityManager before attempting requests, and queue write operations in local storage with a client-generated idempotency key for safe replay on reconnection. Use iOS URLSessionConfiguration.waitsForConnectivity = true to let the OS hold requests until connectivity is available without app-layer polling.

How do I batch multiple JSON API requests to reduce mobile network overhead?

Create a POST /batch endpoint that accepts an array of sub-requests — each with method, path, and optional body — and returns an array of sub-responses with status and body. Process all sub-requests in parallel with Promise.all() on the server, not sequentially. Limit batch size to 10–20 sub-requests to bound memory use and tail latency. On the client, implement automatic coalescing: accumulate all API calls made within a 50 ms window and combine them into a single batch POST, returning individual Promises to each caller. This is transparent to the rest of the app — any API call automatically participates in batching without explicit coordination. The Backend for Frontend (BFF) pattern is a complementary approach: a composite endpoint (GET /screens/home) pre-defines the batch for a specific screen. Combine batching with Brotli compression — JSON arrays of similar objects compress extremely well, and a batch response is typically 30–40% smaller than the sum of its individual compressed parts.

Further reading and primary sources