Distributed ETL with DuckDB-WASM (Node.js)
This tutorial builds a small but real ETL job: an insurance company’s quarterly claims, split by region, distributed across the DCP network, and summarized into a company-wide loss report, using DuckDB compiled to WebAssembly as the query engine inside each worker.
The insurance example is deliberately small; the capability behind it isn’t. DuckDB-WASM gives every DCP worker a real, embedded SQL engine, able to query Parquet and CSV directly and run joins, aggregations, and window functions against them. That’s the same class of workload most ETL pipelines run today on a managed Spark cluster (Databricks and similar): SQL-driven transformation over large tabular data. Doing it here instead means that SQL logic and its distribution across machines both run on DCP, at a fraction of the cost of a managed cluster you have to provision, size, and pay for whether or not it’s busy.
It assumes you’ve already seen the basic DCP job tutorial;
this one reuses the same shape (input set → work function → compute.for →
events → results) and adds one new ingredient: real SQL queries running
inside a sandboxed worker.
Prerequisites
Node.js (LTS)
npm i dcp-clientKeystore files in
~/.dcp(see the basic tutorial if you don’t have these yet)A DuckDB-WASM build, published to the DCP package manager as
duckdbwasmand available to every job: just addjob.requires(['duckdbwasm/duckdb-wasm.js'])as shown below, nothing to set up yourself. It’s WASM rather than the nativeduckdbnpm package because DCP workers run in a sandboxed, browser-like environment and can’t load native binaries. The published build already includes the extensions needed for common ETL workloads; if yours needs one that isn’t there, email dan@dcp.dev and it’ll get added.
The scenario
A regional insurer wants a quarterly loss summary (claim count, total paid, average claim size) broken down by policy type, for every region, then rolled up into one company-wide number, with any individually large claim flagged for review. Each region’s claims are independent of every other region’s, which makes this an ideal shape for distributed compute: one region’s claims are one unit of work, and a DCP worker can summarize a region completely on its own using nothing but the rows it was handed and the review threshold every worker shares.
Building it, concept by concept
Input set
One element per region. Each element carries that region’s raw claims, nothing shared, nothing another region’s worker needs to know about:
const inputSet = [
{
region: 'northeast',
claims: [
{ policyType: 'auto', amount: 4250.00, status: 'paid' },
{ policyType: 'auto', amount: 900.00, status: 'denied' },
{ policyType: 'home', amount: 12800.50, status: 'paid' },
{ policyType: 'auto', amount: 3100.75, status: 'paid' },
{ policyType: 'home', amount: 2200.00, status: 'paid' },
],
},
{
region: 'southeast',
claims: [
{ policyType: 'auto', amount: 5100.00, status: 'paid' },
{ policyType: 'home', amount: 800.00, status: 'denied' },
{ policyType: 'auto', amount: 2750.25, status: 'paid' },
{ policyType: 'flood', amount: 41200.00, status: 'paid' },
],
},
{
region: 'midwest',
claims: [
{ policyType: 'auto', amount: 3300.00, status: 'paid' },
{ policyType: 'home', amount: 9100.00, status: 'paid' },
{ policyType: 'auto', amount: 600.00, status: 'denied' },
{ policyType: 'home', amount: 1450.00, status: 'paid' },
{ policyType: 'auto', amount: 2900.00, status: 'paid' },
],
},
];
A real job would pull each region’s claims from wherever your claims system
exports them (Parquet or CSV extracts are the common case; duckdb-wasm
can query those directly out of an in-memory buffer via
db.registerFileBuffer(), no filesystem needed). Small inline arrays keep
this tutorial’s data on the page; the query logic below doesn’t change if
each element becomes “a buffer of Parquet bytes” instead of “an array of
objects.”
Static arguments
Not everything a work function needs varies by region. The threshold above
which a claim counts as “large” and gets flagged for review is a single
business rule the whole company shares, not something to repeat inside
every inputSet element or hardcode inside workFunction itself.
compute.for takes a third argument for exactly this: an array of values
passed once, delivered to every invocation of the work function alongside
that invocation’s own input-set element.
const LARGE_CLAIM_THRESHOLD = 10000;
workFunction picks it up as an extra parameter after the input-set
element, and job creation passes it through as a one-element array:
async function workFunction({ region, claims }, largeClaimThreshold) { /* ... */ }
const job = compute.for(inputSet, workFunction, [LARGE_CLAIM_THRESHOLD]);
Every worker gets the same largeClaimThreshold, without it ever
appearing in the sharded data. Static arguments are the right home for
anything like this: a config value, a lookup table, a shared constant,
whatever every slice needs identically rather than individually.
Work function
This is where the actual ETL work happens, once per region, inside a sandboxed DCP worker. It loads that region’s claims into an in-memory DuckDB table, then runs a real SQL query against it, the same query you’d hand to a data warehouse: paid claims, grouped by policy type, with count/total/average, plus how many of those claims clear the shared large-claim threshold.
async function workFunction({ region, claims }, largeClaimThreshold) {
progress();
const duckdb = require('duckdb-wasm.js'); // the published package, via job.requires() below
const db = await duckdb.createDB();
const con = db.connect();
con.query(`
CREATE TABLE claims (policy_type VARCHAR, amount DOUBLE, status VARCHAR)
`);
// Prepared statement, not string-interpolated SQL -- worth doing even
// here, since it's the habit that matters once this is real claims data.
const insert = con.prepare('INSERT INTO claims VALUES (?, ?, ?)');
for (const c of claims) insert.query(c.policyType, c.amount, c.status);
insert.close();
// largeClaimThreshold came in as a static argument (see above), not part
// of claims -- every region's worker applies the same threshold here.
const stmt = con.prepare(`
SELECT policy_type,
COUNT(*)::INTEGER AS claim_count,
SUM(amount) AS total_paid,
ROUND(AVG(amount), 2) AS avg_claim,
SUM(CASE WHEN amount >= ? THEN 1 ELSE 0 END)::INTEGER AS large_claim_count
FROM claims
WHERE status = 'paid'
GROUP BY policy_type
ORDER BY policy_type
`);
const result = stmt.query(largeClaimThreshold);
stmt.close();
// duckdb-wasm returns query results as Arrow rows -- schema.fields + a
// plain toArray() turns them into ordinary JS objects.
const fields = result.schema.fields.map((f) => f.name);
const byPolicyType = result.toArray().map((row) => {
const out = {};
for (const f of fields) out[f] = row[f];
return out;
});
con.close();
return { region, byPolicyType };
}
Job creation
const job = compute.for(inputSet, workFunction, [LARGE_CLAIM_THRESHOLD]);
Job configuration
job.requires() is what makes require('duckdb-wasm.js') resolve inside
the worker; without it, that line throws:
job.requires(['duckdbwasm/duckdb-wasm.js']);
job.public = {
name: 'insuranceLossSummary',
description: 'Per-region claims loss summary computed with DuckDB-wasm',
link: 'https://distributive.network',
};
job.computeGroups = [{ joinKey: 'public' }];
Events
job.on('readystatechange', (ev) => console.log(`Ready state: ${ev}`));
job.on('accepted', () => console.log(` Job id: ${job.id}\n Awaiting results...`));
job.on('result', (ev) => console.log(` ${ev.result.region} done`));
job.on('error', (error) => console.error(' Job error:', error));
job.on('nofunds', (ev) => console.log(ev));
Execution
const results = await job.exec();
Result post-processing
Each region came back as its own little report. Rolling them into one company-wide number is the same aggregation idea as the SQL above, just done in plain JS across the (small) number of regions rather than across the (large) number of claims:
const companyWide = {};
for (const { region, byPolicyType } of results) {
console.log(`\n${region}:`);
for (const row of byPolicyType) {
console.log(` ${row.policy_type.padEnd(6)} claims=${row.claim_count} paid=$${row.total_paid.toFixed(2)} avg=$${row.avg_claim} large=${row.large_claim_count}`);
const agg = companyWide[row.policy_type] || { claimCount: 0, totalPaid: 0, largeClaimCount: 0 };
agg.claimCount += row.claim_count;
agg.totalPaid += row.total_paid;
agg.largeClaimCount += row.large_claim_count;
companyWide[row.policy_type] = agg;
}
}
console.log('\nCompany-wide, all regions:');
for (const [policyType, agg] of Object.entries(companyWide)) {
console.log(` ${policyType.padEnd(6)} claims=${agg.claimCount} paid=$${agg.totalPaid.toFixed(2)} large=${agg.largeClaimCount}`);
}
Full listing
async function main() {
const compute = require('dcp/compute');
/* INPUT SET */
const inputSet = [
{
region: 'northeast',
claims: [
{ policyType: 'auto', amount: 4250.00, status: 'paid' },
{ policyType: 'auto', amount: 900.00, status: 'denied' },
{ policyType: 'home', amount: 12800.50, status: 'paid' },
{ policyType: 'auto', amount: 3100.75, status: 'paid' },
{ policyType: 'home', amount: 2200.00, status: 'paid' },
],
},
{
region: 'southeast',
claims: [
{ policyType: 'auto', amount: 5100.00, status: 'paid' },
{ policyType: 'home', amount: 800.00, status: 'denied' },
{ policyType: 'auto', amount: 2750.25, status: 'paid' },
{ policyType: 'flood', amount: 41200.00, status: 'paid' },
],
},
{
region: 'midwest',
claims: [
{ policyType: 'auto', amount: 3300.00, status: 'paid' },
{ policyType: 'home', amount: 9100.00, status: 'paid' },
{ policyType: 'auto', amount: 600.00, status: 'denied' },
{ policyType: 'home', amount: 1450.00, status: 'paid' },
{ policyType: 'auto', amount: 2900.00, status: 'paid' },
],
},
];
/* STATIC ARGUMENTS -- shared by every worker, not part of the input set */
const LARGE_CLAIM_THRESHOLD = 10000;
/* WORK FUNCTION */
async function workFunction({ region, claims }, largeClaimThreshold) {
progress();
const duckdb = require('duckdb-wasm.js');
const db = await duckdb.createDB();
const con = db.connect();
con.query(`
CREATE TABLE claims (policy_type VARCHAR, amount DOUBLE, status VARCHAR)
`);
const insert = con.prepare('INSERT INTO claims VALUES (?, ?, ?)');
for (const c of claims) insert.query(c.policyType, c.amount, c.status);
insert.close();
const stmt = con.prepare(`
SELECT policy_type,
COUNT(*)::INTEGER AS claim_count,
SUM(amount) AS total_paid,
ROUND(AVG(amount), 2) AS avg_claim,
SUM(CASE WHEN amount >= ? THEN 1 ELSE 0 END)::INTEGER AS large_claim_count
FROM claims
WHERE status = 'paid'
GROUP BY policy_type
ORDER BY policy_type
`);
const result = stmt.query(largeClaimThreshold);
stmt.close();
const fields = result.schema.fields.map((f) => f.name);
const byPolicyType = result.toArray().map((row) => {
const out = {};
for (const f of fields) out[f] = row[f];
return out;
});
con.close();
return { region, byPolicyType };
}
/* JOB CREATION */
const job = compute.for(inputSet, workFunction, [LARGE_CLAIM_THRESHOLD]);
/* JOB CONFIGURATION */
job.requires(['duckdbwasm/duckdb-wasm.js']);
job.public = {
name: 'insuranceLossSummary',
description: 'Per-region claims loss summary computed with DuckDB-wasm',
link: 'https://distributive.network',
};
job.computeGroups = [{ joinKey: 'public' }];
/* EVENTS */
job.on('readystatechange', (ev) => console.log(`Ready state: ${ev}`));
job.on('accepted', () => console.log(` Job id: ${job.id}\n Awaiting results...`));
job.on('result', (ev) => console.log(` ${ev.result.region} done`));
job.on('error', (error) => console.error(' Job error:', error));
job.on('nofunds', (ev) => console.log(ev));
/* EXECUTION */
const results = await job.exec();
/* RESULT POST-PROCESSING */
const companyWide = {};
for (const { region, byPolicyType } of results) {
console.log(`\n${region}:`);
for (const row of byPolicyType) {
console.log(` ${row.policy_type.padEnd(6)} claims=${row.claim_count} paid=$${row.total_paid.toFixed(2)} avg=$${row.avg_claim} large=${row.large_claim_count}`);
const agg = companyWide[row.policy_type] || { claimCount: 0, totalPaid: 0, largeClaimCount: 0 };
agg.claimCount += row.claim_count;
agg.totalPaid += row.total_paid;
agg.largeClaimCount += row.large_claim_count;
companyWide[row.policy_type] = agg;
}
}
console.log('\nCompany-wide, all regions:');
for (const [policyType, agg] of Object.entries(companyWide)) {
console.log(` ${policyType.padEnd(6)} claims=${agg.claimCount} paid=$${agg.totalPaid.toFixed(2)} large=${agg.largeClaimCount}`);
}
}
require('dcp-client').init('https://scheduler.distributed.computer').then(main).catch(console.error);
Running it
node claims-loss-summary.js
Expected output (dollar figures will match the sample data above):
Ready state: ...
Job id: ...
Awaiting results...
northeast done
southeast done
midwest done
northeast:
auto claims=2 paid=$7350.75 avg=$3675.38 large=0
home claims=2 paid=$15000.50 avg=$7500.25 large=1
southeast:
auto claims=2 paid=$7850.25 avg=$3925.13 large=0
flood claims=1 paid=$41200.00 avg=$41200 large=1
midwest:
auto claims=2 paid=$6200.00 avg=$3100 large=0
home claims=2 paid=$10550.00 avg=$5275 large=0
Company-wide, all regions:
auto claims=6 paid=$21401.00 large=0
home claims=4 paid=$25550.50 large=1
flood claims=1 paid=$41200.00 large=1
Going further
Bigger, real data: swap the inline
claimsarrays for Parquet or CSV files exported from your claims system, and load them withdb.registerFileBuffer(name, bytes)+read_parquet('name')/read_csv_auto('name')inside the work function instead ofINSERTstatements. The rest of the query doesn’t change.Wider SQL: this tutorial’s query deliberately stays inside
DOUBLEand an explicit::INTEGERcast onCOUNT(*), so every result column comes back as a plain JS number. Reach forDATE/TIMESTAMPcolumns orSUMover largeINTEGERcolumns and you’ll get values back wrapped as Arrow types (epoch milliseconds for dates, aDecimalBigNum-style object for wide sums) that need one extra conversion step, keyed offresult.schema.fields[i].type.typeId. Worth knowing about before it surprises you, not a reason to avoid richer SQL.Sharding by something other than region: the input set is just an array: shard by policy type, by month, by claims adjuster, whatever divides your real workload into independent units. One slice, one worker, one self-contained answer is the whole pattern.
More static arguments: the same pattern from
LARGE_CLAIM_THRESHOLDextends to anything every worker needs identically, a currency-conversion table, a set of valid policy types to validate against, per-tenant config. Pass it once as the third argument tocompute.for, not duplicated into every input-set element.