ndarray vs ndarray-ops vs ndarray-pack vs ndarray-scratch
Multidimensional Array Manipulation in JavaScript
ndarrayndarray-opsndarray-packndarray-scratch

Multidimensional Array Manipulation in JavaScript

The ndarray, ndarray-ops, ndarray-pack, and ndarray-scratch packages form a foundational toolkit for working with multidimensional numeric arrays in JavaScript. ndarray provides the core data structure — a typed array-backed n-dimensional array with shape, stride, and offset metadata. ndarray-ops supplies in-place mathematical and logical operations on these arrays. ndarray-pack converts nested JavaScript arrays into ndarray instances. ndarray-scratch offers temporary allocation and deallocation utilities to minimize memory churn during computation-heavy workflows.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
ndarray01,246-226 years agoMIT
ndarray-ops068-512 years agoMIT
ndarray-pack010-110 years agoMIT
ndarray-scratch012-511 years agoMIT

Multidimensional Array Handling in JavaScript: ndarray Core vs Utilities

Working with images, signals, or scientific data in the browser often means dealing with grids of numbers — not just flat lists, but matrices or even 3D+ volumes. The ndarray ecosystem gives you tools to handle this efficiently without falling back to slow nested loops or unnecessary copies. Let’s look at how each package fits into real-world workflows.

🧱 Core Data Structure: Creating and Representing Arrays

ndarray is the foundation — it wraps a typed array (like Float64Array) with metadata (shape, stride, offset) so you can treat it as a true multidimensional array.

// Create a 2x2 ndarray from a typed array
import ndarray from 'ndarray';
const arr = ndarray(new Float64Array([1, 2, 3, 4]), [2, 2]);
console.log(arr.get(0, 1)); // → 2
console.log(arr.shape);     // → [2, 2]

ndarray-pack helps you go from plain JS arrays to ndarray:

// Convert nested JS arrays into an ndarray
import pack from 'ndarray-pack';
const jsArray = [[1, 2], [3, 4]];
const arr = pack(jsArray);
// Result is an ndarray with shape [2, 2], backed by a Float64Array

ndarray-scratch doesn’t create persistent arrays — it gives you temporary ones:

// Allocate a temporary 3x3 buffer
import scratch from 'ndarray-scratch';
const temp = scratch.malloc([3, 3], 'float64');
// Use it...
scratch.free(temp); // Must manually free

ndarray-ops doesn’t create arrays at all — it operates on existing ones.

💡 Key insight: ndarray is your persistent data container. ndarray-pack is for one-time initialization from JS structures. ndarray-scratch is for short-lived intermediates. ndarray-ops is your math engine.

⚙️ Performing Operations: In-Place Math vs Allocation

ndarray-ops performs fast, in-place operations — no new arrays are created unless explicitly needed.

import ops from 'ndarray-ops';
import ndarray from 'ndarray';

const a = ndarray(new Float64Array([1, 2, 3, 4]), [2, 2]);
const b = ndarray(new Float64Array([5, 6, 7, 8]), [2, 2]);
const out = ndarray(new Float64Array(4), [2, 2]);

// out = a + b
ops.add(out, a, b);

// Or modify 'a' directly: a = a * 2
ops.mulseq(a, 2);

In contrast, ndarray itself only provides basic access (get/set) and slicing — not math.

// Slicing creates a view (no copy)
const row0 = arr.hi(1, Infinity).lo(0, 0); // First row
row0.set(0, 10); // Modifies original array!

ndarray-pack and ndarray-scratch don’t perform operations — they only manage allocation.

⚠️ Warning: ndarray-ops functions like add, mul, etc., assume all inputs have compatible shapes. Mismatched shapes will cause runtime errors — there’s no automatic broadcasting.

♻️ Memory Management: When to Reuse Buffers

If you’re writing a convolution or FFT function, you’ll likely need temporary buffers. Allocating new arrays every call causes GC thrash.

ndarray-scratch solves this with manual pooling:

function myConvolve(input) {
  const temp = scratch.malloc(input.shape, 'float64');
  // ... do work using temp ...
  const result = /* compute final output */;
  scratch.free(temp);
  return result;
}

Compare that to using ndarray-pack, which always allocates:

// Avoid in hot paths
const temp = pack([[0, 0], [0, 0]]); // New allocation every time

ndarray-ops helps here too — because it works in place, you can reuse the same output buffer across calls:

const reusableOut = ndarray(new Float64Array(N), [rows, cols]);

function processFrame(frameIn) {
  ops.mul(reusableOut, frameIn, gain);
  ops.addseq(reusableOut, bias);
  return reusableOut; // Same buffer, updated contents
}

🔄 Real Workflow: From Input to Computation

Imagine loading image data from a <canvas> and applying a brightness filter.

  1. Input: You get a flat Uint8ClampedArray from getImageData().
  2. Wrap: Use ndarray to interpret it as height × width × 4 (RGBA):
    const pixels = ndarray(imgData.data, [height, width, 4]);
    
  3. Temporary buffer: If you need to isolate the red channel:
    const red = scratch.malloc([height, width], 'uint8');
    // Copy red channel (index 0 in last dim)
    for (let i = 0; i < height; i++) {
      for (let j = 0; j < width; j++) {
        red.set(i, j, pixels.get(i, j, 0));
      }
    }
    
  4. Apply operation:
    ops.mulseq(red, 1.5); // Brighten in place
    
  5. Write back (if needed) and clean up:
    scratch.free(red);
    

You wouldn’t use ndarray-pack here — the data isn’t coming from nested JS arrays. You wouldn’t use ndarray-ops alone — you still need the ndarray structure to represent the data.

📦 Interdependence: They’re Meant to Be Used Together

These packages aren’t competitors — they’re layers in a stack:

  • ndarray: The data container.
  • ndarray-pack: One-way converter from JS arrays → ndarray.
  • ndarray-scratch: Allocator/deallocator for transient ndarrays.
  • ndarray-ops: Math library that consumes and mutates ndarrays.

A typical numerical routine might look like:

import ndarray from 'ndarray';
import pack from 'ndarray-pack';
import scratch from 'ndarray-scratch';
import ops from 'ndarray-ops';

function normalize(dataJS) {
  // 1. Convert input
  const input = pack(dataJS);
  
  // 2. Allocate temp buffer
  const temp = scratch.malloc(input.shape, 'float64');
  
  // 3. Compute mean
  let sum = 0;
  for (let i = 0; i < input.size; i++) sum += input.data[i];
  const mean = sum / input.size;
  
  // 4. Subtract mean in place
  ops.subseq(temp, mean); // Wait — this won't work!
  
  // Correction: first copy input to temp
  temp.data.set(input.data);
  ops.subseq(temp, mean);
  
  // 5. Clean up and return
  const result = ndarray(temp.data.slice(), temp.shape);
  scratch.free(temp);
  return result;
}

🔍 Note: ndarray-ops functions like subseq operate on the array passed in — so you must ensure it’s writable and the right type.

⚠️ Maintenance Status and Modern Alternatives

As of 2024, all four packages (ndarray, ndarray-ops, ndarray-pack, ndarray-scratch) are unmaintained. Their GitHub repositories show no recent activity, and the npm pages lack deprecation notices but also show no updates in years. While they work and are stable for simple use cases, consider modern alternatives like:

  • TensorScript (successor effort by scijs)
  • stdlib for numerical computing
  • gpu.js for GPU-accelerated array ops
  • Plain WebAssembly + typed arrays for maximum performance

However, if you’re maintaining legacy code or need a lightweight, zero-dependency solution for small-scale array math, this ecosystem still holds up — just avoid relying on it for new large-scale projects.

✅ Summary: Who Does What

PackageRoleUse When…
ndarrayCore n-dimensional array typeYou need to represent or slice grid data
ndarray-opsIn-place math/logic operationsYou’re doing arithmetic on ndarrays
ndarray-packJS array → ndarray converterInitializing from [[1,2],[3,4]]-style data
ndarray-scratchTemporary buffer allocatorWriting performance-sensitive numerical code

💡 Final Advice

Think of this ecosystem like a C numerical library ported to JavaScript: minimal, fast, and unforgiving. You get control over memory and computation, but you also manage lifetimes and shapes yourself. Use ndarray as your data backbone, ndarray-ops for math, ndarray-pack for setup, and ndarray-scratch to avoid allocation bottlenecks — but verify that unmaintained status aligns with your project’s risk tolerance before committing to it in production.

How to Choose: ndarray vs ndarray-ops vs ndarray-pack vs ndarray-scratch

  • ndarray:

    Choose ndarray when you need a low-level, efficient representation of multidimensional numeric data backed by typed arrays. It’s the essential base type for scientific computing in JavaScript and should be used whenever you’re building or consuming numerical algorithms that require strided access, slicing, or views without copying data.

  • ndarray-ops:

    Choose ndarray-ops when you need to perform common mathematical, comparison, or logical operations (like addition, multiplication, or thresholding) directly on ndarray instances. It modifies arrays in place for performance and is ideal for tight loops or signal/image processing pipelines where memory allocation must be minimized.

  • ndarray-pack:

    Choose ndarray-pack when you have nested JavaScript arrays (e.g., [[1, 2], [3, 4]]) and need to convert them into a proper ndarray for computation. It’s the go-to utility for initializing ndarrays from JSON-like data or user input, but avoid it in performance-critical paths since it always allocates new memory.

  • ndarray-scratch:

    Choose ndarray-scratch when your algorithm requires temporary work buffers that can be reused across function calls to reduce garbage collection pressure. It’s particularly useful in recursive or iterative numerical routines (like FFTs or convolutions) where intermediate arrays are short-lived but frequently allocated.

README for ndarray

ndarray

Modular multidimensional arrays for JavaScript.

browser support

build status

stable

Browse a number of ndarray-compatible modules in the scijs documentation
Coming from MATLAB or numpy? See: scijs/ndarray for MATLAB users
Big list of ndarray modules

Introduction

ndarrays provide higher dimensional views of 1D arrays. For example, here is how you can turn a length 4 typed array into an nd-array:

var mat = ndarray(new Float64Array([1, 0, 0, 1]), [2,2])

//Now:
//
// mat = 1 0
//       0 1
//

Once you have an nd-array you can access elements using .set and .get. For example, here is an implementation of Conway's game of life using ndarrays:

function stepLife(next_state, cur_state) {

  //Get array shape
  var nx = cur_state.shape[0], 
      ny = cur_state.shape[1]

  //Loop over all cells
  for(var i=1; i<nx-1; ++i) {
    for(var j=1; j<ny-1; ++j) {

      //Count neighbors
      var n = 0
      for(var dx=-1; dx<=1; ++dx) {
        for(var dy=-1; dy<=1; ++dy) {
          if(dx === 0 && dy === 0) {
            continue
          }
          n += cur_state.get(i+dx, j+dy)
        }
      }
      
      //Update state according to rule
      if(n === 3 || n === 3 + cur_state.get(i,j)) {
        next_state.set(i,j,1)
      } else {
        next_state.set(i,j,0)
      }
    }
  }
}

You can also pull out views of ndarrays without copying the underlying elements. Here is an example showing how to update part of a subarray:

var x = ndarray(new Float32Array(25), [5, 5])
var y = x.hi(4,4).lo(1,1)

for(var i=0; i<y.shape[0]; ++i) {
  for(var j=0; j<y.shape[1]; ++j) {
    y.set(i,j,1)
  }
}

//Now:
//    x = 0 0 0 0 0
//        0 1 1 1 0
//        0 1 1 1 0
//        0 1 1 1 0
//        0 0 0 0 0

ndarrays can be transposed, flipped, sheared and sliced in constant time per operation. They are useful for representing images, audio, volume graphics, matrices, strings and much more. They work both in node.js and with browserify.

Install

Install the library using npm:

npm install ndarray

You can also use ndarrays in a browser with any tool that follows the CommonJS/node module conventions. The most direct way to do this is to use browserify. If you want live-reloading for faster debugging, check out beefy.

API

Once you have ndarray installed, you can use it in your project as follows:

var ndarray = require("ndarray")

Constructor

ndarray(data[, shape, stride, offset])

The default module.exports method is the constructor for ndarrays. It creates an n-dimensional array view wrapping an underlying storage type

  • data is a 1D array storage. It is either an instance of Array, a typed array, or an object that implements get(), set(), .length
  • shape is the shape of the view (Default: data.length)
  • stride is the resulting stride of the new array. (Default: row major)
  • offset is the offset to start the view (Default: 0)

Returns an n-dimensional array view of the buffer

Members

The central concept in ndarray is the idea of a view. The way these work is very similar to SciPy's array slices. Views are affine projections to 1D storage types. To better understand what this means, let's first look at the properties of the view object. It has exactly 4 variables:

  • array.data - The underlying 1D storage for the multidimensional array
  • array.shape - The shape of the typed array
  • array.stride - The layout of the typed array in memory
  • array.offset - The starting offset of the array in memory

Keeping a separate stride means that we can use the same data structure to support both row major and column major storage

Element Access

To access elements of the array, you can use the set/get methods:

array.get(i,j,...)

Retrieves element i,j,... from the array. In psuedocode, this is implemented as follows:

function get(i,j,...) {
  return this.data[this.offset + this.stride[0] * i + this.stride[1] * j + ... ]
}

array.set(i,j,...,v)

Sets element i,j,... to v. Again, in psuedocode this works like this:

function set(i,j,...,v) {
  return this.data[this.offset + this.stride[0] * i + this.stride[1] * j + ... ] = v
}

array.index(i,j, ...)

Retrieves the index of the cell in the underlying ndarray. In JS,

function index(i,j, ...) {
  return this.offset + this.stride[0] * i + this.stride[1] * j + ...
}

Properties

The following properties are created using Object.defineProperty and do not take up any physical memory. They can be useful in calculations involving ndarrays

array.dtype

Returns a string representing the undelying data type of the ndarray. Excluding generic data stores these types are compatible with typedarray-pool. This is mapped according to the following rules:

Data typeString
Int8Array"int8"
Int16Array"int16"
Int32Array"int32"
Uint8Array"uint8"
Uint16Array"uint16"
Uint32Array"uint32"
BigInt64Array"bigint64"
BigUint64Array"biguint64"
Float32Array"float32"
Float64Array"float64"
Array"array"
Uint8ArrayClamped"uint8_clamped"
Buffer"buffer"
Other"generic"

Generic arrays access elements of the underlying 1D store using get()/set() instead of array accessors.

array.size

Returns the size of the array in logical elements.

array.order

Returns the order of the stride of the array, sorted in ascending length. The first element is the first index of the shortest stride and the last is the index the longest stride.

array.dimension

Returns the dimension of the array.

Slicing

Given a view, we can change the indexing by shifting, truncating or permuting the strides. This lets us perform operations like array reversals or matrix transpose in constant time (well, technically O(shape.length), but since shape.length is typically less than 4, it might as well be). To make life simpler, the following interfaces are exposed:

array.lo(i,j,k,...)

This creates a shifted view of the array. Think of it as taking the upper left corner of the image and dragging it inward by an amount equal to (i,j,k...).

array.hi(i,j,k,...)

This does the dual of array.lo(). Instead of shifting from the top-left, it truncates from the bottom-right of the array, returning a smaller array object. Using hi and lo in combination lets you select ranges in the middle of an array.

Note: hi and lo do not commute. In general:

a.hi(3,3).lo(3,3)  !=  a.lo(3,3).hi(3,3)

array.step(i,j,k...)

Changes the stride length by rescaling. Negative indices flip axes. For example, here is how you create a reversed view of a 1D array:

var reversed = a.step(-1)

You can also change the step size to be greater than 1 if you like, letting you skip entries of a list. For example, here is how to split an array into even and odd components:

var evens = a.step(2)
var odds = a.lo(1).step(2)

array.transpose(p0, p1, ...)

Finally, for higher dimensional arrays you can transpose the indices without replicating the data. This has the effect of permuting the shape and stride values and placing the result in a new view of the same data. For example, in a 2D array you can calculate the matrix transpose by:

M.transpose(1, 0)

Or if you have a 3D volume image, you can shift the axes using more generic transformations:

volume.transpose(2, 0, 1)

array.pick(p0, p1, ...)

You can also pull out a subarray from an ndarray by fixing a particular axis. The way this works is you specify the direction you are picking by giving a list of values. For example, if you have an image stored as an nxmx3 array you can pull out the channel as follows:

var red   = image.pick(null, null, 0)
var green = image.pick(null, null, 1)
var blue  = image.pick(null, null, 2)

As the above example illustrates, passing a negative or non-numeric value to a coordinate in pick skips that index.

More information

For more discussion about ndarrays, here are some talks, tutorials and articles about them:

License

(c) 2013-2016 Mikola Lysenko. MIT License