sdxc

[ Data & background work ]

Stream JSON Lines

Export records as a JSON Lines download that streams as it is read, import uploads row by row with errors that name the line, and resume a long import where it stopped.

Last updated 2026-10-15

JSON Lines is one JSON value per line. It is the format to reach for when records carry nested data that a CSV would flatten, and when a file is too large to hold in memory: each line stands alone, so a reader handles one record at a time and a writer never builds the whole file. The same format goes by NDJSON.

This guide exports an account's activity events as a download and imports them back, with @sdxc/jsonl. Its readers answer a Result per line with the line's number, cap how long a line may be, and resume after the rows an earlier run already handled.

npm add @sdxc/http @sdxc/jsonl @sdxc/result remix

Export as a stream

encode turns an iterable or async iterable of values into a byte stream, pulling one value per line as the response is read, so memory stays flat however many records the export holds:

app/http/controllers/activity/export.ts
import { encode } from "@sdxc/jsonl";
import { attachment } from "@sdxc/http/response";
import { createAction } from "remix/router";

import { activityEvents } from "~/app/models/activity";
import routes from "~/routes/web";

export default createAction(routes.activity.export, (ctx) => {
	let body = encode(activityEvents(ctx.db, ctx.account.id));
	return new Response(body, {
		headers: {
			"Content-Type": "application/jsonl",
			"Content-Disposition": attachment("activity.jsonl"),
			"Cache-Control": "no-store",
		},
	});
});

activityEvents is your own async generator that reads the table a page at a time, as the keyset-paged one in Import and export CSV does. Each value is written with JSON.stringify on its own line, so a Date arrives as an ISO 8601 string. A value JSON.stringify cannot write, such as a BigInt or a cycle, errors the stream with the line it would have taken, and the download fails visibly rather than arriving cut short. If the client disconnects, cancelling the stream returns the generator, so no further page is read.

Import an upload row by row

read takes a byte stream, such as a request body, and yields one Result per non-blank line. With a schema, which can be any Standard Schema, each value is validated as it is read and entry.data.value is typed from the schema's output:

app/services/imports/activity-event.ts
import * as s from "remix/data-schema";

export const ACTIVITY_EVENT = s.object({
	type: s.enum_(["signed_in", "invited", "exported"]),
	actor: s.string(),
	at: s.string(),
	data: s.optional(s.record(s.string(), s.any())),
});
app/services/imports/read-activity.ts
import { read } from "@sdxc/jsonl";
import { isFailure } from "@sdxc/result";
import * as s from "remix/data-schema";

import { ACTIVITY_EVENT } from "~/app/services/imports/activity-event";

export interface ImportProblem {
	line: number;
	message: string;
}

export async function readActivity(body: ReadableStream<Uint8Array>) {
	let events: s.InferOutput<typeof ACTIVITY_EVENT>[] = [];
	let problems: ImportProblem[] = [];

	for await (let entry of read(body, {
		schema: ACTIVITY_EVENT,
		maxLineBytes: 65_536,
	})) {
		if (isFailure(entry)) {
			problems.push({ line: entry.error.line, message: entry.error.message });
			continue;
		}
		events.push(entry.data.value);
	}

	return { events, problems };
}

A line that is not JSON, or that the schema rejects, yields a failure and reading continues, so one pass collects every problem in the file. The failure's code says which it was: "invalid-json", or "invalid-value" with the schema's issues beside it. line counts blank lines too, which the reader otherwise skips, so it is the number an editor shows beside the row. A \r\n ending and a leading byte order mark read the same as their absence.

The action reads request.body straight into readActivity, so the file is never buffered whole, then saves events or renders problems. Save nothing while problems holds anything, so fixing the file and uploading it again cannot duplicate the rows that were fine.

Bound what a line may cost

maxLineBytes caps one line, in encoded bytes. The buffer only ever holds the line being read, so this cap is what keeps an upload of one enormous line from growing memory without end. A longer line yields one "line-too-long" failure and reading stops there, which makes it always the last entry read yields: the loop above records it and ends on its own.

Set it from the largest record you expect, with room to spare. Cap the body as a whole where it is accepted, too: maxLineBytes bounds memory, while the request's size bounds how long a single import may run.

Resume a long import

An import of millions of rows outlives a request and, on a Worker, a single job's time limit. skip drops that many values before the first one yielded, so a run that stops can start over from the same file and pick up after the rows it already saved:

app/services/imports/import-batch.ts
import type { Database } from "remix/data-table";

import { read } from "@sdxc/jsonl";
import { isFailure } from "@sdxc/result";

import { Activity } from "~/app/models/activity";
import { ACTIVITY_EVENT } from "~/app/services/imports/activity-event";

const BATCH = 1_000;

export async function importBatch(
	db: Database,
	file: ReadableStream<Uint8Array>,
	cursor: number,
) {
	let saved = 0;
	for await (let entry of read(file, { schema: ACTIVITY_EVENT, skip: cursor })) {
		if (isFailure(entry)) return { cursor: cursor + saved, error: entry.error };
		await Activity.insert(db, entry.data.value);
		saved += 1;
		if (saved === BATCH) return { cursor: cursor + saved, done: false };
	}
	return { cursor: cursor + saved, done: true };
}

skip counts values rather than lines, so the cursor lands on the same record whatever blank lines the file holds, and the lines after it keep their real line numbers for any error. A job that calls importBatch with the file from storage enqueues itself again with the new cursor until done; returning early from the loop releases the stream without reading the rest. Background jobs and cron writes that job.

Copy lines without parsing them

lines yields each non-blank line as text with its number, and encodeLines writes lines that are already JSON back out byte for byte. Together they filter or split a file without re-serializing every record:

app/services/exports/only-invites.ts
import { encodeLines, lines } from "@sdxc/jsonl";
import { isSuccess } from "@sdxc/result";

async function* invites(source: ReadableStream<Uint8Array>) {
	for await (let line of lines(source, { maxLineBytes: 65_536 })) {
		if (isSuccess(line) && line.data.text.includes('"type":"invited"')) {
			yield line.data.text;
		}
	}
}

export function onlyInvites(source: ReadableStream<Uint8Array>) {
	return encodeLines(invites(source));
}

Bytes are split at \n before they are decoded, so a character that straddles two chunks of the stream decodes once, intact.

Small files in memory

For a file that fits in memory, parse reads every value or answers the first line that is not JSON, and stringify writes values as one string ending in a newline, each in a Result:

app/services/fixtures.ts
import { parse, stringify } from "@sdxc/jsonl";

let events = parse('{"type":"invited"}\n\n{"type":"signed_in"}\n');
let text = stringify([{ type: "exported" }]);

Where to go next

Written by Sergio Xalambrí. Follow @sergiodxa for new packages, or sponsor the work.