惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

人人都是产品经理
人人都是产品经理
Stack Overflow Blog
Stack Overflow Blog
L
LINUX DO - 最新话题
Google Online Security Blog
Google Online Security Blog
Schneier on Security
Schneier on Security
Spread Privacy
Spread Privacy
www.infosecurity-magazine.com
www.infosecurity-magazine.com
雷峰网
雷峰网
Google DeepMind News
Google DeepMind News
Microsoft Azure Blog
Microsoft Azure Blog
IT之家
IT之家
V
Vulnerabilities – Threatpost
K
Kaspersky official blog
S
Schneier on Security
B
Blog
The Register - Security
The Register - Security
SecWiki News
SecWiki News
Hacker News: Ask HN
Hacker News: Ask HN
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
Security Affairs
T
The Blog of Author Tim Ferriss
G
Google Developers Blog
T
Tenable Blog
P
Proofpoint News Feed
Apple Machine Learning Research
Apple Machine Learning Research
D
DataBreaches.Net
S
Secure Thoughts
Security Latest
Security Latest
H
Heimdal Security Blog
The Hacker News
The Hacker News
O
OpenAI News
AWS News Blog
AWS News Blog
量子位
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
腾讯CDC
U
Unit 42
L
Lohrmann on Cybersecurity
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
L
LangChain Blog
阮一峰的网络日志
阮一峰的网络日志
T
The Exploit Database - CXSecurity.com
NISL@THU
NISL@THU
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
Application and Cybersecurity Blog
Application and Cybersecurity Blog
Hugging Face - Blog
Hugging Face - Blog
The Last Watchdog
The Last Watchdog
Recorded Future
Recorded Future
V2EX - 技术
V2EX - 技术
爱范儿
爱范儿
F
Full Disclosure

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Why your app feels slow and how we fixed it with PowerSync
Doszhan Meng · 2026-05-10 · via DEV Community

The honeymoon phase of every MVP

You know the feeling: while you’re building an MVP, everything flies. A couple of users, an empty database, a fast server. The user clicks a button, the frontend sends a request, the backend responds, and the UI updates. It all feels predictable and straightforward.

At that stage, it’s easy to believe the architecture will scale just fine. Queries are fast, tables are small, and the user flows are simple. Every form saves in a split second. Every list opens right away.

Then the product grows up. Lists get longer, filters get more complex, analytics shows up, table relationships get messier, and the number of users keeps climbing.

We ran into that while building Finsight. In products like this, there are a lot of reads: transactions, categories, filters, totals, month views, quick edits. If every screen has to wait for the server, the whole product starts to feel heavier.

Before long, the user is spending more time staring at loaders than actually using the app. Open a list, wait. Change a field, wait again. The internet seems fine, the server is up, and yet the product still feels sluggish.

That’s a rough moment. Especially when, technically, everything seems to be built the right way.

The usual treatment

In that situation, we usually go down the well-worn path.

We check PostgreSQL indexes. Add pagination. Cache endpoints. Move heavy calculations out of hot paths. Run EXPLAIN ANALYZE. Remove unnecessary JOINs. Split large queries into smaller ones. Optimize serializers. Add debounce on the frontend.

All of that matters. And it often does help.

But in our case, it became clear that the problem was not just a slow backend. The real problem was the request-and-wait architecture itself.

The classic flow looked like this:

click -> request -> wait -> response -> update UI

Enter fullscreen mode Exit fullscreen mode

As long as the network and the backend are fast, this feels fine. But the moment mobile internet gets shaky, the server takes a little longer, or the database pauses on a heavy query, the interface becomes trapped by the wait.

The user can’t keep going until the app gets its answer. Every action turns into a small negotiation with the network.

At some point, we decided we were done tolerating that and took a different path.

Local-first: when data is always close

We moved to an architecture where the main data source for the interface is a local SQLite database on the user’s device.

Important disclaimer: the backend did not go anywhere.

It still handles authentication, permissions, business rules, and validation. PostgreSQL remains the central store. But React no longer has to call the API every time it needs to show a list or update a field on screen.

The flow became:

React UI -> Local SQLite -> PowerSync -> Backend -> PostgreSQL

Enter fullscreen mode Exit fullscreen mode

Now when the user hits save, the record lands in the local database first, the UI updates almost immediately, and PowerSync sends the change to the backend in the background.

click -> local write -> update UI -> sync in background

Enter fullscreen mode Exit fullscreen mode

The network still matters. It just no longer stands between the user and the interface.

That was the real shift. Not speeding up one query, but removing the network wait from the user’s main interaction loop.

How it works inside

The frontend works with local SQLite through PowerSync. Components do not know about a separate API for every screen. They read through hooks or a DAL layer that runs SQL queries against the local database.

The backend changes roles too. It is no longer the layer returning JSON for every render. It becomes the place where permissions, constraints, relationships between entities, and incoming operations from the upload queue are checked.

PowerSync handles the synchronization layer. It delivers data to the client, keeps local SQLite in sync, and sends local changes back upstream.

Press enter or click to view image in full size

We do not download the whole database

One of the first questions people ask is whether the whole database ends up on the user’s device.

No.

A core part of PowerSync is partial replication. The client receives only the rows the user is allowed to access.

For example, if a user belongs to several workspaces, they get data only for those workspaces. Everything else never reaches the device.

A simplified sync_rules.yaml:

bucket_definitions:
  by_workspace:
    parameters: |
      SELECT workspace_id
      FROM workspace_memberships
      WHERE user_id = request.user_id()

    data:
      - SELECT * FROM records WHERE workspace_id = bucket.workspace_id
      - SELECT * FROM categories WHERE workspace_id = bucket.workspace_id

Enter fullscreen mode Exit fullscreen mode

Extra data simply never gets synchronized. That gives you two big advantages.

First, the user never physically receives rows that belong to someone else.

Second, the backend and PostgreSQL are involved in far fewer routine reads. Lists, sorting, filters, and part of the analytics can all run locally.

For example, a list screen can open with a regular SQL query on the frontend:

SELECT *
FROM records
WHERE workspace_id = ?
ORDER BY created_at DESC
LIMIT 50;

Enter fullscreen mode Exit fullscreen mode

If you need an index, that lives locally too:

CREATE INDEX records_workspace_created_at_idx
ON records (workspace_id, created_at);

Enter fullscreen mode Exit fullscreen mode

This is a normal database sitting right next to the user. Not a just-in-case cache, but a real data source for the interface.

This is where the UI starts to feel faster. Opening a list no longer depends on a round trip to the server. A filter does not turn into another API call. Sorting does not wait for a database on the other side of the world. Analytics can run right on the device.

We did not do formal before-and-after benchmarks. In our case, the main problem was not that the backend responded too slowly. It was that the interface depended on network speed far too often.

To the user, this does not look like “we optimized a query.” The product simply behaves differently: the screen appears right away, transitions feel calmer, and loaders disappear from places where they used to feel inevitable.

The backend and PostgreSQL still matter. They are involved in synchronization, initial loading, permission checks, and persistence. But a routine screen read no longer has to go through the API every single time.

A separate token for sync

We separated the app’s normal authorization flow from access to the sync layer.

Download the Medium App
PowerSync uses a separate short-lived JWT. The client calls the regular API, the backend validates the user, and then issues a token specifically for sync.

class GetPowerSyncToken(APIView):
    permission_classes = [IsAuthenticated]

    def get(self, request):
        token = create_powersync_jwt(str(request.user.id))

        return Response({
            "token": token,
            "powersync_url": settings.POWERSYNC_URL,
        })

Enter fullscreen mode Exit fullscreen mode

PowerSync checks the claims in that token and uses them when applying sync rules.

That split turned out to be convenient. The regular app session has its own lifecycle. Sync gets a separate short-lived pass.

Local mutations

The biggest shift on the frontend was that we stopped treating a save as an immediate POST to the backend.

The frontend updates the local database first.

await powerSync.writeTransaction(async (tx) => {
  await tx.execute(
    `INSERT INTO records (id, workspace_id, amount, created_at)
     VALUES (?, ?, ?, ?)`,
    [id, workspaceId, amount, createdAt]
  );

  await tx.execute(
    `UPDATE categories
        SET usage_count = COALESCE(usage_count, 0) + 1
      WHERE id = ?`,
    [categoryId]
  );
});

Enter fullscreen mode Exit fullscreen mode

One local transaction can update several related entities.

The user does not have to wait for the server to confirm anything. They see the result immediately, while synchronization and validation catch up in the background.

Upload is a separate pipeline

Offline changes how people use the app.

The same record might be edited several times before the app gets a connection again.

update title
update amount
update category
update title again

Enter fullscreen mode Exit fullscreen mode

If you send every intermediate state to the server, you create a lot of noise. In most cases, the backend needs the final version of the row, not the entire story of how the user got there.

So before upload, we compact the queue.

const transaction = await database.getNextCrudTransaction();
const byKey = new Map();

for (const item of transaction.crud || []) {
  const key = `${item.table}::${item.id}`;
  const previous = byKey.get(key);

  byKey.set(
    key,
    previous ? mergeOperations(previous, item) : item
  );
}

const batch = [...byKey.values()];

await postBatchWithRetries(uploadUrl, batch);
await transaction.complete();

Enter fullscreen mode Exit fullscreen mode

We group operations by row and send only what actually needs to be applied on the server.

Less noise. Fewer duplicate operations. Fewer strange edge cases when the connection comes back.

The backend is still in charge

Local-first does not mean the frontend becomes trusted.

Yes, the user writes data locally first. Yes, the UI updates immediately. But the backend still validates every operation that arrives from the queue.

for index, operation in enumerate(batch):
    try:
        with transaction.atomic():
            action = operation["op"]
            table = operation["table"]
            row_id = operation["id"]
            data = operation.get("data", {})

            if action == "PUT":
                apply_put(table, row_id, data)
            elif action == "PATCH":
                apply_patch(table, row_id, data)
            elif action == "DELETE":
                apply_delete(table, row_id)
            else:
                raise ValidationError("Unsupported operation")

    except ValidationError as exc:
        errors.append({
            "index": index,
            "table": operation.get("table"),
            "id": operation.get("id"),
            "retryable": False,
            "detail": str(exc),
        })

Enter fullscreen mode Exit fullscreen mode

Permissions, limits, entity relationships, field validation, all of that still lives on the server.

PowerSync helps move changes around. It should not become a way around business logic or security.

Cross-platform became simpler

Another practical upside is that the same code can be used across different platforms.

In our case, one approach works for web, PWA, Android TWA, and an iOS WebView wrapper. The shells are different, but the data logic stays shared.

Platform-specific details do not disappear. Storage, permissions, push notifications, background behavior, you still have to think about all of that, especially on mobile.

But the code itself does not have to be rewritten for every platform. Reads are local. Writes are local. Sync happens in the background.

For users, that feels much closer to a native app, even if there is still a web UI under the hood.

Minimal self-hosted deployment

You can run this architecture with Docker Compose.

At minimum, you need frontend, backend, PowerSync, and PostgreSQL.

services:
  frontend:
    build:
      context: ./frontend
    ports:
      - "4173:4173"

  backend:
    build:
      context: ./backend
    ports:
      - "8000:8000"

  powersync:
    build:
      context: ./powersync
    command: ["start", "-r", "unified"]
    ports:
      - "7001:7001"
    volumes:
      - ./powersync/config:/config

  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: app
      POSTGRES_USER: app
      POSTGRES_PASSWORD: change_me

Enter fullscreen mode Exit fullscreen mode

A simplified PowerSync config:

replication:
  connections:
    - type: postgresql
      uri: !env PS_DATA_SOURCE_URI
      sslmode: disable

storage:
  type: postgresql
  uri: !env PS_STORAGE_PG_URI
  sslmode: disable

sync_rules:
  path: sync_rules.yaml

client_auth:
  jwks_uri: !env PS_JWKS_URL
  audience:
    - !env PS_AUDIENCE

Enter fullscreen mode Exit fullscreen mode

PS_DATA_SOURCE_URI points to the main PostgreSQL database.

PS_STORAGE_PG_URI is used for PowerSync’s own storage.

PS_JWKS_URL lets PowerSync validate JWTs.

The tradeoffs

Nobody warned us how uncomfortable the middle ground would feel.

Migrations. We didn’t want to write migration files for the frontend — it felt wrong, like maintaining a database on the client. So we took the lazy path: on schema change, wipe the local database and rebuild from sync. It works. But the first time a user opened the app after an update and stared at a loading screen while everything re-synced, we felt it. Not a crash, not a bug — just a bad moment that didn’t have to happen.

Conflicts. We had a real one. User A edited a record while offline. User B edited the same record online — change landed on the server. User A came back online, upload queue fired, and quietly overwrote User B. Last Write Wins did exactly what it was supposed to do. That was the problem. Nobody lost data in a way the system could detect. It just disappeared.

The mental cost. The hardest part isn’t the code. It’s that you now have two databases to keep in sync — local and server — and when something looks wrong, you have no idea which one is lying. We’ve caught ourselves wanting to just add a normal API endpoint because it would be easier to reason about. Sometimes that instinct is right. Sometimes it’s just habit. Hard to tell in the moment.

Security is also an open question. The local database sits on the user’s device. PowerSync handles access control through sync rules, and the backend validates every upload. But the surface area is larger than a classic API. Something to think about before you go local-first with sensitive data.

Was it worth it?

Honestly, the first two weeks after the switch were rough. Local state, upload queue, sync layer, server — debugging something meant figuring out which of the four places was lying to you. We broke things. We fixed them. We broke them again in a different way.

But then it clicked.

And now, when I open Finsight on a shaky subway connection and the UI just reacts without a single loading spinner, I remember why we did it. The app finally feels “light” again, just like it did in the early MVP days, but now it’s built to scale.

Next time, I will write about how we added E2E encryption on top of this local database, so even we cannot see what users store.