DEV Community

Cover image for Run SQLite in the Browser with Blazor WebAssembly: A Practical Step‑by‑Step Guide
David Au Yeung
David Au Yeung

Posted on

Run SQLite in the Browser with Blazor WebAssembly: A Practical Step‑by‑Step Guide

Introduction

In this exercise, we'll learn how to run a real SQLite database entirely in the browser using Blazor WebAssembly and sql.js.

No server.

No connection string.

No Docker.

Everything runs inside the user's browser tab, powered by WebAssembly.

We'll build a simple Blazor page that loads the popular Chinook sample database, runs SQL queries client-side, and renders the results in a table.

This approach works great for:

  • Offline‑first demos and prototypes
  • Data exploration tools
  • Training content and workshops
  • Scenarios where you want "database feel" without any backend

Step 1: Add sql.js to Your Blazor WebAssembly App

First, we need the sql.js library (SQLite compiled to WebAssembly for browsers).

Create a folder under wwwroot:

  • wwwroot/sqljs/

Download the following files from the official sql.js GitHub releases (or a CDN) and place them in wwwroot/sqljs/:

Your structure should look like:

wwwroot/
  sqljs/
    sql-wasm.js
    sql-wasm.wasm
Enter fullscreen mode Exit fullscreen mode

Step 2: Create a Thin JavaScript Wrapper

Next, we create a small JavaScript interop layer that:

  • Loads sql-wasm.js and initializes SQL.js
  • Opens databases from byte arrays
  • Executes SQL and returns rows as JavaScript objects
  • Exports and closes databases

Add a new file: wwwroot/sqljs/sqljsInterop.js:

window.sqliteJs = {
  init: async () => {
    try {
      console.log("sqliteJs.init: Starting initialization...");
      if (window.SQL) {
        console.log("sqliteJs.init: Already initialized");
        return true;
      }

      console.log("sqliteJs.init: Loading sql-wasm.js...");
      await new Promise((resolve, reject) => {
        const s = document.createElement("script");
        s.src = "/sqljs/sql-wasm.js";
        s.onload = () => {
          console.log("sqliteJs.init: sql-wasm.js loaded successfully");
          resolve();
        };
        s.onerror = (e) => {
          console.error("sqliteJs.init: Failed to load sql-wasm.js", e);
          reject(e);
        };
        document.head.appendChild(s);
      });

      console.log("sqliteJs.init: Initializing SQL.js...");
      window.SQL = await initSqlJs({
        locateFile: f => {
          console.log("sqliteJs.init: Locating file:", f);
          return "/sqljs/" + f;
        }
      });

      window._dbs = {};
      console.log("sqliteJs.init: Initialization complete");
      return true;
    } catch (error) {
      console.error("sqliteJs.init: Error during initialization", error);
      throw error;
    }
  },

  openDb: (bytes) => {
    try {
      console.log("sqliteJs.openDb: Opening database, bytes:", bytes ? bytes.length : 0);
      const db = bytes ? new SQL.Database(new Uint8Array(bytes))
                       : new SQL.Database();
      const id = crypto.randomUUID();
      window._dbs[id] = db;
      console.log("sqliteJs.openDb: Database opened with ID:", id);
      return id;
    } catch (error) {
      console.error("sqliteJs.openDb: Error opening database", error);
      throw error;
    }
  },

  exec: (id, sql, params) => {
    try {
      console.log("sqliteJs.exec: Executing query", { id, sql, params });
      const db = window._dbs[id];
      if (!db) {
        throw new Error("Database not found: " + id);
      }
      const stmt = db.prepare(sql);
      if (params) stmt.bind(params);
      const rows = [];
      while (stmt.step()) rows.push(stmt.getAsObject());
      stmt.free();
      console.log("sqliteJs.exec: Query returned", rows.length, "rows");
      return rows;
    } catch (error) {
      console.error("sqliteJs.exec: Error executing query", error);
      throw error;
    }
  },

  export: (id) => {
    try {
      console.log("sqliteJs.export: Exporting database", id);
      return window._dbs[id].export();
    } catch (error) {
      console.error("sqliteJs.export: Error exporting database", error);
      throw error;
    }
  },

  close: (id) => {
    try {
      console.log("sqliteJs.close: Closing database", id);
      window._dbs[id].close();
      delete window._dbs[id];
    } catch (error) {
      console.error("sqliteJs.close: Error closing database", error);
      throw error;
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Step 3: Reference the Interop Script in index.html

Blazor WebAssembly apps use wwwroot/index.html as the host page.

Open wwwroot/index.html and add the sql.js interop script before the Blazor script:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>WASMDemowithBlazorApp</title>
    <base href="/" />
    <link rel="preload" id="webassembly" />
    <link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
    <link rel="stylesheet" href="css/app.css" />
    <link rel="icon" type="image/png" href="favicon.png" />
    <link href="WASMDemowithBlazorApp.styles.css" rel="stylesheet" />
    <script type="importmap"></script>
</head>

<body>
    <div id="app">
        <svg class="loading-progress">
            <circle r="40%" cx="50%" cy="50%" />
            <circle r="40%" cx="50%" cy="50%" />
        </svg>
        <div class="loading-progress-text"></div>
    </div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="." class="reload">Reload</a>
        <span class="dismiss">🗙</span>
    </div>

    <!-- sql.js interop -->
    <script src="/sqljs/sqljsInterop.js"></script>

    <!-- Blazor WebAssembly -->
    <script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

Step 4: Create a C# SqlJsService for JS Interop

Now we wrap the JavaScript API with a clean C# service.

Add a new class: Services/SqlJsService.cs:

using System.Text.Json;
using Microsoft.JSInterop;

namespace WASMDemowithBlazorApp.Services;

public class SqlJsService
{
    private readonly IJSRuntime _js;
    private bool _init;

    public SqlJsService(IJSRuntime js)
    {
        _js = js;
    }

    public async Task InitAsync()
    {
        if (_init)
        {
            return;
        }

        await _js.InvokeVoidAsync("sqliteJs.init");
        _init = true;
    }

    public Task<string> OpenAsync(byte[]? bytes = null)
    {
        return _js.InvokeAsync<string>("sqliteJs.openDb", bytes).AsTask();
    }

    public async Task<List<Dictionary<string, object>>> QueryAsync(
        string id,
        string sql,
        object[]? args = null)
    {
        var raw = await _js.InvokeAsync<object>("sqliteJs.exec", id, sql, args);

        // Convert JS objects -> JSON -> Dictionary<string, object>
        return JsonSerializer.Deserialize<List<Dictionary<string, object>>>(
                   JsonSerializer.Serialize(raw))
               ?? new List<Dictionary<string, object>>();
    }

    public Task<byte[]> ExportAsync(string id)
    {
        return _js.InvokeAsync<byte[]>("sqliteJs.export", id).AsTask();
    }

    public Task CloseAsync(string id)
    {
        return _js.InvokeVoidAsync("sqliteJs.close", id).AsTask();
    }
}
Enter fullscreen mode Exit fullscreen mode

Register the service in Program.cs:

using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using WASMDemowithBlazorApp;
using WASMDemowithBlazorApp.Services;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");

builder.Services.AddScoped(sp => new HttpClient
{
    BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});

// Register SQL.js service
builder.Services.AddScoped<SqlJsService>();

await builder.Build().RunAsync();
Enter fullscreen mode Exit fullscreen mode

Step 5: Add the Chinook SQLite Database

We'll use the classic Chinook sample database (music store data).

  1. Create a folder:
wwwroot/
  chinook/
    chinook.sqlite
Enter fullscreen mode Exit fullscreen mode
  1. Download Chinook_Sqlite.sqlite from the Chinook GitHub repo and rename it to chinook.sqlite.
  2. Place it under wwwroot/chinook/chinook.sqlite.

This file will be served as a static asset and loaded into memory by the browser.

Step 6: Build the Blazor Page to Query SQLite

Now we create a Blazor component that:

  • Loads the database file via HttpClient
  • Opens it with SqlJsService
  • Executes a SELECT against the Album table
  • Renders the first 10 albums

Create Pages/Chinook.razor:

@page "/chinook"
@inject Services.SqlJsService Sql
@inject HttpClient Http
@implements IAsyncDisposable

<PageTitle>Chinook Database</PageTitle>

<h1>Chinook Database Demo</h1>

<p>This page demonstrates client-side SQLite using sql.js in Blazor WebAssembly.</p>

@if (rows == null)
{
    <p><em>Loading database...</em></p>
}
else if (errorMessage != null)
{
    <div class="alert alert-danger" role="alert">
        <strong>Error:</strong> @errorMessage
    </div>
}
else
{
    <h3>Albums (First 10)</h3>
    <table class="table">
        <thead>
            <tr>
                <th>Album ID</th>
                <th>Title</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var r in rows)
            {
                <tr>
                    <td>@r["AlbumId"]</td>
                    <td>@r["Name"]</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    string? dbId;
    List<Dictionary<string, object>>? rows;
    string? errorMessage;

    protected override async Task OnInitializedAsync()
    {
        try
        {
            Console.WriteLine("Starting SQL initialization...");
            await Sql.InitAsync();
            Console.WriteLine("SQL initialized successfully");

            Console.WriteLine("Fetching database file...");
            var bytes = await Http.GetByteArrayAsync("chinook/chinook.sqlite");
            Console.WriteLine($"Database loaded: {bytes.Length} bytes");

            Console.WriteLine("Opening database...");
            dbId = await Sql.OpenAsync(bytes);
            Console.WriteLine($"Database opened with ID: {dbId}");

            Console.WriteLine("Executing query...");
            rows = await Sql.QueryAsync(
                dbId,
                "SELECT AlbumId, Title AS Name FROM Album LIMIT 10"
            );
            Console.WriteLine($"Query returned {rows.Count} rows");
        }
        catch (Exception ex)
        {
            errorMessage = $"{ex.GetType().Name}: {ex.Message}";
            Console.WriteLine($"Error: {errorMessage}");
            Console.WriteLine($"Stack trace: {ex.StackTrace}");
        }
    }

    public async ValueTask DisposeAsync()
    {
        if (dbId != null)
        {
            try
            {
                await Sql.CloseAsync(dbId);
            }
            catch
            {
                // Ignore dispose errors
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Finally, add a nav link (optional, but nice) in Layout/NavMenu.razor:

<div class="nav-item px-3">
    <NavLink class="nav-link" href="chinook">
        <span class="bi bi-database" aria-hidden="true"></span> Chinook DB
    </NavLink>
</div>
Enter fullscreen mode Exit fullscreen mode

Step 7: Run and See the Result

Run the Blazor WebAssembly app:

dotnet run
Enter fullscreen mode Exit fullscreen mode

Navigate to:

https://localhost:xxxx/chinook
Enter fullscreen mode Exit fullscreen mode

You should see:

  • A loading message
  • Then a table of the first 10 albums from the Album table rendered directly from SQLite in the browser.

No API controller. No EF Core. Just pure WASM + Blazor + sql.js.

What We Learned (and Things to Be Careful About)

While wiring this up, a few important lessons surfaced:

  1. Static file locks during build

    • Visual Studio may complain that chinook.sqlite is in use during build if some process has it open.
    • If you see IOException: The process cannot access the file ... chinook.sqlite, close any external tools (SQLite browser, etc.) and stop the running dev server before rebuilding.
  2. SQLite table names are case-sensitive here

    • The Chinook schema uses Album, not albums.
    • A tiny mismatch like FROM albums results in Error: no such table: albums deep in a JS stack trace.
    • When troubleshooting, run a quick SELECT name FROM sqlite_master WHERE type='table'; using a local SQLite client to confirm table names.
  3. Blazor + JSInterop error surfacing

    • SQL errors show up first in the browser console (JS), then are re-thrown into .NET as JSException.
    • Logging in both C# (Console.WriteLine) and JS (console.log, console.error) makes it much easier to trace problems across the boundary.
  4. Disposal still matters in WebAssembly

    • Even though everything is in-memory, it's good practice to implement IAsyncDisposable on your Blazor components and close the in-memory databases when the component is torn down.
    • This keeps your in-browser resources tidy, especially if you open multiple databases.
  5. Script load order is important

    • sqljsInterop.js must load before Blazor starts calling sqliteJs.init.
    • Keep the interop <script> tag before _framework/blazor.webassembly*.js in index.html.

These small details are easy to miss when you're new to mixing Blazor, JS interop, and WebAssembly, but they're exactly what make the experience smooth once you know them.

Summary

In this exercise, we:

  • Integrated sql.js (SQLite on WebAssembly) into a Blazor WebAssembly app.
  • Created a JavaScript interop layer to load, open, query, export, and close SQLite databases in the browser.
  • Wrapped that logic in a C# SqlJsService, exposing a clean async API.
  • Loaded the Chinook sample database from wwwroot, executed a SQL query against the Album table, and rendered results in a Blazor component.
  • Learned several practical troubleshooting tips for working with Blazor + WebAssembly + JS interop.

With this foundation, you can:

  • Build offline data explorers
  • Ship demo apps that "feel" like full-stack but run entirely in the browser
  • Prototype data-driven features without standing up real infrastructure

Love C# & Blazor WASM!

Top comments (1)

Collapse
 
auyeungdavid_2847435260 profile image
David Au Yeung

This will be my last article of the year. I hope you all have a happy New Year and enjoy the article!