FastAPI is my default for mobile backends. It's fast, auto-generates OpenAPI docs, and async support handles the burst traffic patterns that mobile apps produce. Here's the setup I use on real projects.
Authentication
Mobile apps need JWT with refresh token rotation. Short-lived access tokens (15 min) and long-lived refresh tokens (30 days) are the right balance — avoids forcing re-login while limiting exposure if a token leaks:
from datetime import datetime, timedelta
from jose import jwt
def create_access_token(user_id: str) -> str:
expire = datetime.utcnow() + timedelta(minutes=15)
return jwt.encode({"sub": user_id, "exp": expire}, settings.secret_key, algorithm="HS256")
def create_refresh_token(user_id: str) -> str:
expire = datetime.utcnow() + timedelta(days=30)
return jwt.encode({"sub": user_id, "exp": expire, "type": "refresh"}, settings.secret_key, algorithm="HS256")Cursor Pagination (Not Offset)
Offset pagination breaks when new content is added between page loads — users see duplicates or miss items. Cursor-based pagination avoids this entirely:
from pydantic import BaseModel
from typing import Generic, TypeVar, Optional
T = TypeVar("T")
class CursorPage(BaseModel, Generic[T]):
items: list[T]
next_cursor: Optional[str] = None
has_more: bool
@router.get("/posts", response_model=CursorPage[PostResponse])
async def list_posts(
cursor: Optional[str] = None,
limit: int = 20,
db: AsyncSession = Depends(get_db),
):
query = select(Post).order_by(Post.created_at.desc()).limit(limit + 1)
if cursor:
query = query.where(Post.created_at < decode_cursor(cursor))
posts = (await db.execute(query)).scalars().all()
has_more = len(posts) > limit
return CursorPage(
items=posts[:limit],
next_cursor=encode_cursor(posts[limit - 1].created_at) if has_more else None,
has_more=has_more,
)The mobile client only needs to track next_cursor — no page numbers, no offset math.
Consistent Error Shape
Every error response should have the same structure so the mobile client can parse it generically:
class AppError(Exception):
def __init__(self, code: str, message: str, status_code: int = 400):
self.code = code
self.message = message
self.status_code = status_code
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError):
return JSONResponse(
status_code=exc.status_code,
content={"error": {"code": exc.code, "message": exc.message}},
)Your Flutter or React Native client always destructures error.code and error.message — no defensive null-checking on every response.
Push Notifications
For FCM, use BackgroundTasks so the route returns immediately — the notification send happens off the request cycle:
from fastapi import BackgroundTasks
@router.post("/notifications/send")
async def send_notification(
payload: NotificationPayload,
background_tasks: BackgroundTasks,
):
background_tasks.add_task(send_fcm_notification, payload)
return {"queued": True}For high volume (hundreds per minute+), swap BackgroundTasks for Celery + Redis. Always validate FCM tokens on send — if FCM returns 404 or UNREGISTERED, remove the stale token from your database immediately.