Why I Compiled My Time Series Forecasting Engine to WebAssembly

Privacy-first forecasting sounds nice in theory. In practice, running statistical packages like ARIMA and exponential smoothing on raw CSVs at the edge meant compiling Rust to WebAssembly. Here is how I built it.

Dan
Dan
5 min read
Cover Image for Why I Compiled My Time Series Forecasting Engine to WebAssembly
Table of Contents

If you build tools for enterprises, you quickly learn a painful truth: handling raw data can be messy.

The moment you ask a financial analyst, a logistics manager, or an operations lead to upload their historical sales or inventory CSV to your servers, you trigger a compliance gauntlet. InfoSec questionnaires, data processing agreements, GDPR/CCPA audits, and the constant fear of data leaks.

But what if you didn't have to touch their raw data at all?

When I built WaySightAI, I set a strict constraint: raw time-series data must never leave the user's browser. The backend should only handle authentication, saved metadata (project names, configurations), usage limits, and high-level AI orchestration. The actual heavy lifting—parsing the CSV, cleaning the data, running stationarity tests, and computing forecasts—had to happen locally.

To do this, I wrote my core time-series math in Rust and compiled it to WebAssembly (WASM). Here is how I structured the hybrid architecture and what I learned along the way.


The Monorepo Architecture

WaySightAI is organized as a monorepo to maintain a clear boundary between the UI, the mathematical engine, and the management layer:

packages/
  web/        React 18 + TypeScript + Vite (renders the workflow UI)
  wasm/       Rust forecasting engine compiled with wasm-pack
  api/        FastAPI backend (SQLAlchemy, Clerk authentication, Redis, Stripe hooks)

The forecasting path follows a strict client-side flow:

  1. The user uploads a CSV in the React web application.
  2. The browser parses the CSV locally and lets the user select date and target columns.
  3. The React app loads the wasm package dynamically and transfers the numeric vectors to the WASM memory buffer.
  4. The Rust/WASM engine processes the calculations and returns JSON containing historical fitted values, forecast points, confidence intervals, and diagnostics.
  5. The React app draws the charts using local data.

Writing the Core Math in Rust

Rust is an ideal language for time-series forecasting. It gives us deterministic memory management, zero-cost abstractions, and an ecosystem of libraries like ndarray and statrs for numerical analysis.

For example, when a user uploads raw data, it often contains missing values or extreme anomalies (outliers) that would break classical statistical models like ARIMA. In my Rust core (packages/wasm/src/preprocessing.rs), we implemented fast IQR-based outlier detection that runs in microseconds:

use serde::{Deserialize, Serialize};
use wasm_bindgen::prelude::*;

#[derive(Serialize, Deserialize)]
pub struct OutlierIndices {
    pub indices: Vec<usize>,
    pub z_scores: Vec<f64>,
}

/// Detect outliers using the IQR method
#[wasm_bindgen]
pub fn detect_outliers_iqr(values: &[f64]) -> Result<JsValue, JsValue> {
    let mut valid_values: Vec<(usize, f64)> = values
        .iter()
        .enumerate()
        .filter(|(_, &v)| v.is_finite())
        .map(|(i, &v)| (i, v))
        .collect();

    if valid_values.len() < 4 {
        return Ok(serde_wasm_bindgen::to_value(&OutlierIndices {
            indices: vec![],
            z_scores: vec![],
        })?);
    }

    // Sort to find quartiles
    valid_values.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());

    let q1_idx = valid_values.len() / 4;
    let q3_idx = (valid_values.len() * 3) / 4;
    let q1 = valid_values[q1_idx].1;
    let q3 = valid_values[q3_idx].1;
    let iqr = q3 - q1;

    let lower_bound = q1 - 1.5 * iqr;
    let upper_bound = q3 + 1.5 * iqr;

    let mean: f64 = valid_values.iter().map(|(_, v)| v).sum::<f64>() / valid_values.len() as f64;
    let variance: f64 = valid_values
        .iter()
        .map(|(_, v)| (v - mean).powi(2))
        .sum::<f64>()
        / valid_values.len() as f64;
    let std_dev = variance.sqrt();

    let mut outlier_indices = Vec::new();
    let mut z_scores = Vec::new();

    for (idx, &val) in values.iter().enumerate() {
        if val.is_finite() && (val < lower_bound || val > upper_bound) {
            outlier_indices.push(idx);
            z_scores.push((val - mean) / std_dev);
        }
    }

    Ok(serde_wasm_bindgen::to_value(&OutlierIndices {
        indices: outlier_indices,
        z_scores,
    })?)
}

By compiling this function via wasm-pack, JavaScript can invoke it directly, passing array buffers and receiving the results back as native JS values via serde_wasm_bindgen.


Bridging Rust and React in the Browser

To make loading the compiled WASM file seamless, I built a TypeScript wrapper class (WasmEngine) that handles dynamic imports, module initialization, and the type conversion between standard JavaScript arrays and WebAssembly-friendly typed arrays (Float64Array).

Here is a simplified version of the loader wrapper:

import type {
  WaySightAIWasm,
  ForecastConfig,
  ForecastResult,
  DataStats
} from '../../types/wasm';

export class WasmEngine {
  private wasmModule: WaySightAIWasm | null = null;
  private isInitialized = false;
  private loadingPromise: Promise<void> | null = null;

  /**
   * Dynamically import and initialize the WASM module
   */
  async load(): Promise<void> {
    if (this.loadingPromise) return this.loadingPromise;
    if (this.isInitialized && this.wasmModule) return Promise.resolve();

    this.loadingPromise = (async () => {
      try {
        // Dynamically import the JS entry point generated by wasm-pack
        const wasmModule = await import('/pkg/waysightai_wasm.js');

        // Initialize the WebAssembly binary instance
        await wasmModule.default();
        wasmModule.init();

        this.wasmModule = wasmModule as unknown as WaySightAIWasm;
        this.isInitialized = true;
        console.log('[WasmEngine] WASM module loaded successfully');
      } catch (error) {
        console.error('[WasmEngine] Failed to load WASM module:', error);
        throw error;
      }
    })();

    await this.loadingPromise;
    this.loadingPromise = null;
  }

  private ensureInitialized(): WaySightAIWasm {
    if (!this.isInitialized || !this.wasmModule) {
      throw new Error('WASM module not initialized. Call load() first.');
    }
    return this.wasmModule;
  }

  /**
   * Run a local forecast job
   */
  async runForecast(
    timestamps: number[],
    values: number[],
    isUnixMs: boolean,
    config: ForecastConfig
  ): Promise<ForecastResult> {
    const wasm = this.ensureInitialized();
    const configJson = JSON.stringify(config);
    
    // Call the exported Rust function
    const resultJson = wasm.run_forecast(timestamps, values, isUnixMs, configJson);
    return JSON.parse(resultJson);
  }
}

Because WebAssembly compilation produces a .wasm file alongside a .js wrapper, Vite allows us to load this dynamically on demand. This keeps the initial page bundle small, downloading the 1.2MB WASM forecasting package only when the user reaches the active editor dashboard.


Performance: Browser vs. Server

Running ARIMA models (which rely on MLE optimization) and triple exponential smoothing (Holt-Winters) in a single-threaded browser environment might sound slow. However, my benchmarks showed surprising results:

Dataset Size (Rows) Operation Execution Location Duration (ms)
500 rows Stationarity Tests (ADF + KPSS) Browser (WASM) 8ms
500 rows ARIMA(1,1,1) Parameter Tuning Browser (WASM) 45ms
5,000 rows Outlier Detection & Imputation Browser (WASM) 12ms
5,000 rows Holt-Winters Forecast (Horizon=30) Browser (WASM) 185ms

For typical business dashboards where datasets range from 100 to 10,000 rows, the execution is effectively instantaneous. In contrast, sending this data over an API to a Python service, waiting for it to parse, compute, serialize, and return would easily take 800ms to 2.5 seconds—even before accounting for server cold starts or database query times.

Furthermore, running the computations locally means we can build interactive visual sliders. If a user wants to test different seasonal parameters (alpha, beta, gamma), they can slide the control in React, and the chart updates in near real-time (under 50ms) as the local WASM engine re-calculates the parameters.


The Security and Cost Tradeoffs

Going fully client-side with WASM is not a silver bullet. It introduces key trade-offs:

  1. WASM Overhead: The first load requires fetching the WASM binary. For users on slow connections, this adds a couple of seconds of initialization delay.
  2. IP Exposure: Because WebAssembly runs on the client, the compiled code is technically accessible. If you have proprietary forecasting models that you want to keep secret, running them in WASM is a risk, as the assembly can be reverse-engineered. For standard algorithms (ARIMA, Simple/Double/Triple Exponential Smoothing), this is not a concern.
  3. CPU Constraints: If a user uploads a dataset with millions of rows, the browser will lag, and you risk hitting browser memory limits. For massive scale, you still need an orchestrator like AWS SageMaker.

However, the cost savings are massive. Since my servers don't perform any math, my FastAPI backend runs on a cheap container instance. I can handle thousands of concurrent users without paying for expensive auto-scaling CPU nodes or GPU workers.

Conclusion

Compiling my forecasting math to WebAssembly turned out to be the single best architectural decision I made. It allowed me to promise absolute data privacy to users, gave sub-100ms forecast updates, and kept server infrastructure costs virtually zero.

If you are building data-heavy SaaS tools, don't default to sending every CSV to a server. Let Rust and WebAssembly do the heavy lifting where the data already lives: right in the browser.