PocketBase v1.0: Single-File Backend That Actually Handles Production Traffic

I was paying Firebase $200/month for an app with 15K users. The bill kept creeping up because of Firestore reads — a single list view could trigger 500 reads. PocketBase v1.0 replaced Firebase entirely: auth, database, file storage, real-time subscriptions — all in one 20MB Go binary backed by SQLite. Monthly cost: $5 for a VPS. Here's how it went.

Why PocketBase Beat Firebase

FeatureFirebasePocketBase v1.0
DatabaseFirestore (NoSQL)SQLite (SQL)
AuthFirebase AuthBuilt-in (email, OAuth2)
File storageCloud StorageBuilt-in local storage
Real-timeFirestore listenersSSE subscriptions
Custom logicCloud FunctionsGo hooks
Cost (15K users)$200/month$5/month VPS
Self-hostedNoYes

v1.0 Breaking Changes

PocketBase v1.0 (stable release) brought these key changes from the beta:

# Download PocketBase v1.0
wget https://github.com/pocketbase/pocketbase/releases/download/v1.0.0/pocketbase_1.0.0_linux_amd64.zip
unzip pocketbase_1.0.0_linux_amd64.zip

# Run (creates data directory automatically)
./pocketbase serve --http=0.0.0.0:8090

# Admin UI: http://localhost:8090/_/
# API: http://localhost:8090/api/

Extending with Go Hooks

This is where PocketBase shines over Firebase. Instead of Cloud Functions in Node.js (cold starts, billing per invocation), you write Go hooks that run in the same process. Zero latency, zero cost per invocation:

// main.go
package main

import (
    "log"
    "net/http"
    "github.com/pocketbase/pocketbase"
    "github.com/pocketbase/pocketbase/core"
    "github.com/pocketbase/pocketbase/models"
)

func main() {
    app := pocketbase.New()

    // Hook: after a user signs up, create a profile record
    app.OnRecordAfterCreateRequest("users", func(e *core.RecordCreateEvent) error {
        profile := models.NewRecord(app.Dao().FindCollectionByNameOrId("profiles"))
        profile.Set("user", e.Record.Id)
        profile.Set("displayName", e.Record.GetString("email"))
        return app.Dao().SaveRecord(profile)
    })

    // Hook: before creating an order, validate stock
    app.OnRecordBeforeCreateRequest("orders", func(e *core.RecordCreateEvent) error {
        productId := e.Record.GetString("product")
        product, err := app.Dao().FindRecordById("products", productId)
        if err != nil {
            return err
        }
        stock := product.GetInt("stock")
        qty := e.Record.GetInt("quantity")
        if qty > stock {
            return fmt.Errorf("only %d items in stock, requested %d", stock, qty)
        }
        return nil
    })

    // Hook: after order created, decrement stock and send notification
    app.OnRecordAfterCreateRequest("orders", func(e *core.RecordCreateEvent) error {
        productId := e.Record.GetString("product")
        product, _ := app.Dao().FindRecordById("products", productId)
        product.Set("stock", product.GetInt("stock") - e.Record.GetInt("quantity"))
        app.Dao().SaveRecord(product)

        // Send email notification (inline, no external service)
        sendOrderConfirmation(e.Record)
        return nil
    })

    // Custom route
    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        e.Router.GET("/api/stats", func(c echo.Context) error {
            totalUsers, _ := app.Dao().DB().Select("count(*)").From("users").Build().FetchOne()
            totalOrders, _ := app.Dao().DB().Select("count(*)").From("orders").Build().FetchOne()
            return c.JSON(http.StatusOK, map[string]any{
                "users":  totalUsers,
                "orders": totalOrders,
            })
        })
        return e.Next()
    })

    if err := app.Start(); err != nil {
        log.Fatal(err)
    }
}
# Build the custom binary
go build -o myapp main.go

# Deploy (just copy the binary + data directory)
scp myapp user@server:/opt/myapp/
# Set up systemd service for auto-restart

Problem

At around 50 concurrent users writing simultaneously, PocketBase started returning database is locked errors. SQLite only allows one writer at a time, and the default busy timeout was too short.

Actual Fix

// main.go — tune SQLite for higher concurrency
func main() {
    app := pocketbase.New()

    app.OnBeforeServe().Add(func(e *core.ServeEvent) error {
        // Increase busy timeout to 5 seconds
        app.Dao().DB().NewQuery("PRAGMA busy_timeout = 5000").Execute()

        // Enable WAL mode (allows concurrent reads during writes)
        app.Dao().DB().NewQuery("PRAGMA journal_mode = WAL").Execute()

        // Set synchronous to NORMAL (faster writes, still safe with WAL)
        app.Dao().DB().NewQuery("PRAGMA synchronous = NORMAL").Execute()

        return e.Next()
    })

    app.Start()
}

Real-time Subscriptions for Chat

PocketBase uses Server-Sent Events (SSE) for real-time. Here's a chat app pattern:

// client-side chat with PocketBase JS SDK
import PocketBase from "pocketbase"
const pb = new PocketBase("https://myapp.example.com")

// Subscribe to new messages
pb.collection("messages").subscribe("*", (e) => {
  if (e.action === "create") {
    appendMessage(e.record)
  }
})

// Send a message (also triggers the subscription above)
async function sendMessage(text, channelId) {
  await pb.collection("messages").create({
    text,
    channel: channelId,
    sender: pb.authStore.record.id,
  })
}

// Unsubscribe on cleanup
function cleanup() {
  pb.collection("messages").unsubscribe("*")
}

Scaling SQLite Past 100K Users

SQLite can handle more than most people think. Key optimizations:

What I Learned

Wrapping Up

PocketBase v1.0 is the single-file backend I've been waiting for. It replaced Firebase and saved me $200/month. The Go extension model means I'm not limited to simple CRUD — I can write real business logic with zero cold start overhead. For any indie developer or small team building a web or mobile app, PocketBase should be your default backend choice.

Related Articles