Engineering · October 2026

DuckDB-WASM for finance work: what it took

Notes from building a workflow engine that runs client ledgers in the browser tab. Cross-origin isolation, Excel files whose header is never on row 1, CSV typing, DECIMAL, dates, the memory ceiling, and re-running a workflow with no model call.

KD, CPA · DuckDB-WASM 1.30 · Next.js 14 · Ekselio by GPTBeyond

Ekselio is a finance workflow builder. You describe an analysis in a sentence, an AR aging, an EBITDA bridge, a month-end pack, and it builds the workflow step by step. Each step is one DuckDB query you can open and read. The engine is DuckDB-WASM, running in the browser tab, not on a server.

That choice came from the audience, not the technology. The files are client ledgers: trial balances, general ledger exports, AR sub-ledgers. An accountant will not upload those to a startup’s server to try a tool, and should not. “Your files are never uploaded” is a sentence an IT department can check in the network tab in thirty seconds, so it has to be literally true. Every join, pivot and running total runs in the tab. The language model is used once, to write the steps. After that the workflow re-runs on next month’s file with no model call at all.

Here is what that took, in the order we hit it.

1. Getting the engine to load

The bundles come from jsDelivr and selectBundle picks the right one for the browser. The multi-threaded build needs SharedArrayBuffer, which needs the page to be cross-origin isolated. We set the two headers only on the routes that run the engine, not site-wide, because they break cross-origin popups such as an OAuth flow we once had to support.

// next.config.mjs — only on the routes that run the engine
{ source: '/try', headers: [
  { key: 'Cross-Origin-Opener-Policy',   value: 'same-origin' },
  { key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' },
]}

A Worker cannot be created from a cross-origin URL, so the worker is a same-origin blob that imports the CDN script. The Excel extension is installed at startup from DuckDB’s extension repository.

const bundles = duckdb.getJsDelivrBundles()
const bundle = await duckdb.selectBundle(bundles)

// A Worker cannot be created from a cross-origin URL, so make a
// same-origin blob worker that imports the CDN worker.
const workerUrl = URL.createObjectURL(
  new Blob([`importScripts("${bundle.mainWorker}");`],
           { type: 'text/javascript' })
)
const db = new duckdb.AsyncDuckDB(new duckdb.ConsoleLogger(),
                                  new Worker(workerUrl))
await db.instantiate(bundle.mainModule, bundle.pthreadWorker)
await db.open({ query: { castDecimalToDouble: true } }) // section 4
const conn = await db.connect()
await conn.query('INSTALL excel;')
await conn.query('LOAD excel;')

The fallout from require-corp is that every cross-origin resource on those routes must send aCross-Origin-Resource-Policy header. jsDelivr does. Google Fonts loaded through a stylesheet @import did not, so Inter silently failed on the product page until the fonts were self-hosted with next/font. Headless browsers are stricter still: Playwright on Chromium blocks the worker under COEP, and the test harness needs --disable-web-security and --enable-features=SharedArrayBuffer before DuckDB will initialise at all.

2. Excel: the header is never on row 1

Finance spreadsheets open with a title block: company name, report name, period, a blank line, then the header. The header is on row 5 or row 8, and the rows above it are not data.

What works for most of them is a two-pass read. First a probe: fifty rows, no header, everything as text. The top twenty rows of that probe go to the model with every number replaced by # and every cell cut to forty characters, and it returns the header row. The shape of the title block leaves the browser; no value does. If that call fails or the browser is offline, a local rule takes over: the first row with at least two non-numeric cells, which is right more often than it sounds. Then the real read, with the header row known.

-- 1. probe: no header, everything as text, first 50 rows
SELECT * FROM read_xlsx('tb.xlsx', sheet='TB',
  header=false, all_varchar=true) LIMIT 50;

-- 2. read, once the header row is known (row 5 here)
SELECT * FROM read_xlsx('tb.xlsx', sheet='TB',
  header=true, all_varchar=true, stop_at_empty=false,
  ignore_errors=true, range='A5:ZZ');

all_varchar=true on the second read is deliberate. Excel’s own typing is not trustworthy: a column of amounts with one “n/a” in it, a date column with two text dates. Every Excel column arrives as text, and the step that uses the column casts it. The cast is in the SQL, where a reviewer can see it, rather than in the reader, where nobody can. Each sheet of a workbook becomes its own file entry.

One case is not solved in the browser path. read_xlsx infers the sheet’s width from the first row, so a workbook whose row 1 is a single cell, the company name alone, collapses to one column before the probe can see anything. A range read does not rescue it: A3:ZZ on such a file explodes into hundreds of empty columns, with dates as raw serials. The desktop build hands those sheets to a Python reader that sees the true cell structure. On the web, that file still comes through as one column today.

3. CSV: pin the type candidates

The CSV sniffer picks the narrowest type that fits, and for a column of positive integers that is an unsigned or small integer. Those came through Arrow into JavaScript as typed values our row conversion did not handle. The fix is to pin the candidate list, and because the model writes the SQL, a small rewrite injects the option into every read_csv_auto call it produces. Amounts in CSV therefore become DOUBLE, never DECIMAL.

SELECT * FROM read_csv_auto('ar.csv',
  auto_type_candidates=['BOOLEAN','BIGINT','DOUBLE',
                        'DATE','TIMESTAMP','VARCHAR'])

4. DECIMAL: let DuckDB do the conversion

DECIMAL still arrives, from Parquet files and from explicit casts in generated SQL. Arrow delivers it as a 128-bit value, which lands in JavaScript as a four-element Uint32Array. The first version of the row converter decoded that by hand and got two things wrong: it ignored the scale, so values came back off by a power of ten, and it ignored two’s complement, so a negative such as -1,234.56 became +4,294,843,840. For financial data that is catastrophic, and quiet. Nothing threw.

The fix is one option at open time, castDecimalToDouble: true. DuckDB converts, correct by construction, and DECIMAL behaves like the DOUBLE columns the rest of the app already handles. A double is exact for integers below 253 and inexact for most decimal fractions, which is the same arithmetic Excel uses. The grid rounds to cents, and the Excel export re-performs the logic in Excel rather than copying numbers across, so what gets signed is Excel’s own result.

5. Dates arrive four ways

Depending on the path, a date reaches the row converter as a JavaScript Date, as a count of days since 1970, as milliseconds, or as an Excel serial sitting in a text column because of all_varchar. The converter normalises all of them to YYYY-MM-DD at the edge, but only above magnitude floors: serials from 10,000 up, epoch days from 10,000 up, milliseconds from 1011, seconds between 108 and 1011, and never an integer between 1900 and 2099, which is a year. Without the floors, a column named period_date holding the value 3 becomes 1970-01-04, which is how the floors got there.

6. The memory ceiling

DuckDB-WASM runs in a 32-bit address space, so four gigabytes is the platform’s hard limit, and browsers sit below it. In practice the engine is not what falls over. The JavaScript side is, the moment a step’s result is materialised into objects for the grid.

So we cap rather than catch. Files over 100 MB are refused and files over 25 MB get a warning. Every step runs COUNT(*) first and pulls at most 50,000 rows into JavaScript; the full result stays inside DuckDB as the temp table the next step reads. Previews show the first fifty rows. There is no out-of-memory handler. The caps are the handler, and a month-end pack for a mid-market company sits comfortably under them.

7. Re-runs with no model call

The model is called when the workflow is built, with the prompt, the column names and types of each file, and up to three sample rows per file. It returns steps. Each step is a node holding one SQL query that reads either a registered file or input_1, input_2 and so on. Running the workflow is a loop over the nodes in topological order, and there is no call to the model inside it. Connector files are refetched before the loop starts; the loop itself is SQL.

// one node = one SQL step; edges carry upstream results in as input_N
for (const node of topologicallySorted) {
  let query = node.sql
  for (const edge of edges.filter(e => e.target === node.id)) {
    const upstream  = outputs.get(edge.source)
    const inputName = edge.targetHandle || 'input_1'       // input_1, input_2 ...
    const temp      = `${node.id}_${inputName}`.replace(/-/g, '_')
    await conn.query(`CREATE OR REPLACE TEMP TABLE ${temp} AS ${upstream.query}`)
    query = query.replace(new RegExp('\\b' + inputName + '\\b', 'gi'), temp)
  }
  const n = Number((await conn.query(`SELECT COUNT(*) AS c FROM (${query})`))
                     .toArray()[0].c)
  const rows = await conn.query(n > 50_000 ? `${query} LIMIT 50000` : query)
  outputs.set(node.id, { query, rowCount: n, rows })
}

The sort is a depth-first search with a visiting set, so a cycle produces a warning and a best-effort order instead of a hang. A saved workflow is the SQL, the layout and a recipe per file. Connector files carry their source and parameters, with rolling windows that advance, so “last 90 days” means the last 90 days at run time. Uploaded files carry their expected columns, and when next month’s file comes in with a renamed column the drift is flagged before anything runs.

For the claim to be honest, the exceptions have to be listed. A failed step can be sent to the model for a fix, with the SQL, the error and the column names. Header detection uses the masked grid above. After a run, a short commentary is written from the summary table. Each of these is a separate, optional call, and none of them is in the run loop. The full list of what leaves the browser is on the privacy page, written so an IT reviewer can check it against the network tab.

What you get at the end

A workflow that re-runs on next month’s file, and an Excel export that carries the same logic as Power Query with a tie-out sheet, so the accountant ends up with a workbook rather than a dashboard. If you build on DuckDB-WASM for anything with money in it: set castDecimalToDouble before your first demo, pin the CSV type candidates, never trust row 1 of an Excel file, and cap what you pull into JavaScript.