Improve load experience with refresh page with existing feed #127
Labels
No labels
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
gtfs.zone/coloring-book#127
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Refreshing the page with a large feed (think MBTA, BART, NYC subway) freezes the tab for many seconds with no visual feedback — the user thinks the app is broken. The frozen time is dominated by main-thread work that has nothing to do with rendering: re-parsing CSV blobs out of IndexedDB, walking every row in the validator, and rebuilding the same in-memory indexes twice (once from blobs, again from snapshot + patches).
We will (1) add the existing
FeedProgressIndicatorto the refresh path so the user sees that something is happening, (2) replace CSV blobs with JSON blobs to makerestoreDataFromDatabase3–10× faster and eliminate the redundantprocessParsedDatapass, (3) move the parse off the main thread into a worker, (4) parallelize independent IDB reads, (5) drop the startup validator (its results are not displayed anyway), and (6) eliminate the snapshot-vs-blob redundancy by tracking the version a blob was last persisted at.Scope is deliberately limited to the restore + first-paint path. The route renderer's
createRouteFeaturesis also slow but stays out of scope — we want the user to at least see the page before the map indexes are built.Tradeoffs:
InfoDisplay is not used in the new UI structure), so this is strictly a wash.Relevant context
Architecture
The app boots via
GTFSEditor.init()insrc/index.ts:127. Module instantiation is synchronous in the constructor; async work happens ininit(). There is no central state — modules own their state and the canonical store is IndexedDB (GTFSDatabase).Current restore path (the slow path on refresh)
GTFSEditor.init()(src/index.ts:127) — orchestrates everything below, sequentially withawait.GTFSParser.initialize()(src/modules/gtfs-parser.ts:459):gtfsDatabase.initialize()— opens IndexedDB.gtfsDatawith header-only CSVs for every known file (cheap).restoreDataFromDatabase()(src/modules/gtfs-parser.ts:483):ALL_GTFS_FILESsequentially withawait. For each.txtfile:await this.gtfsDatabase.getTableBlob(tableName)— IDB read.Papa.parse(csv, { header: true, skipEmptyLines: true })— synchronous, main thread. For a 100 MBstop_times.txtthis is multi-second.processParsedData(parsed.data)—.map()over every row, walks every field, runsparseFieldValue(a long if-tree on field names) to coerce types. Allocates a brand new object per row.setupVirtual(tableName, data)— buildsbyIdMap (composite key per row) + anyfieldMaps(e.g.stop_timesindexed bytrip_idandstop_id).PatchManager.initialize()(src/modules/patch-manager.ts:42):getVersions(),getLatestSnapshot().DecompressionStream),JSON.parse, then for each table:db.clearTable(table)(wipes the just-built virtual table) →db.insertRows(table, rows)(rebuilds it) →parser.setInMemoryFileData(...)(callssetupVirtualyet again with the new array). This is pure duplicate work whenever the blob is already current.snapshot.version + 1tocurrentVersionviaapplyPatchForward(each patch is one virtual-table mutation).MapController.initialize()(src/modules/map-controller.ts:85):validateAndUpdateInfo()(src/index.ts:365):gtfsValidator.validateFeed()synchronously. This iterates every row ofstop_timesand runs per-field validation (regex-backedvalidateValuecalls). For a 2M-row feed this is many seconds of main-thread work — and the comment atsrc/index.ts:369says the result is not displayed anywhere.pageStateManager.initializeFromURL()+processURLCommands+mapController.updateMap():updateMap()→routeRenderer.renderRoutes()→createRouteFeatures()(src/modules/route-renderer.ts:202). This walks every shape point, every stop_time, every trip, every route. Also expensive, but out of scope for this plan — it runs after the first paint and the user has already seen the UI.browseNavigation.refresh()— renders the sidebar list. Fast.Key files & functions
src/index.ts:127—GTFSEditor.init()(orchestration)src/modules/gtfs-parser.ts:459—GTFSParser.initialize()src/modules/gtfs-parser.ts:483—GTFSParser.restoreDataFromDatabase()(the main hot path)src/modules/gtfs-parser.ts:407—GTFSParser.persistDirtyBlobs()(debounced write path; needs to switch to JSON too)src/modules/gtfs-parser.ts:425—GTFSParser.generateCSVFromRows()(delete after switch)src/modules/gtfs-parser.ts:128—GTFSParser.processParsedData()(delete after switch — only used for parsing CSV from blob, which goes away)src/modules/gtfs-parser.ts:64—GTFSParser.parseFieldValue()(delete after switch — same reason)src/modules/gtfs-database.ts:1027—saveTableBlob(tableName, csv: string)— change signature to JSONsrc/modules/gtfs-database.ts:1038—getTableBlob(tableName): string | null— change signature to JSONsrc/modules/gtfs-database.ts:213—GTFSDatabase.initialize()(DB version bump)src/modules/gtfs-database.ts:155(approx) —file_blobsstore schemasrc/modules/feed-progress-indicator.ts— already exists, just needs to be used during restoresrc/modules/patch-manager.ts:42—PatchManager.initialize()(snapshot vs blob dedup)src/modules/gtfs-validator.ts:448—validateStopTimes()(offending row loop)src/workers/gtfs-parser.worker.ts— existing import worker; we will add a siblinggtfs-restore.worker.ts(see Phase 3 gotcha)Invariants to preserve
getAll/getById/querycalls must return shallow copies.gtfs-parser.ts:160):gtfsData[fileName].datamust be the same array reference as the virtual table'sflatarray. After restoration, both must point at the same in-memory array.initializeEmpty()runs (src/index.ts:336). The new path must still satisfygetAllFileNames()/getFileDataSync()checks.insertdedups bybyId,deleteis no-op when missing, so replaying patches that were already applied (because blob was current) remains safe. This invariant lets us skip patch replay as an optimization (Phase 5) but doesn't require us to.gtfs-database.ts:213already handles version upgrades with an "Export & Continue / Clear & Continue" modal — we get the user-facing migration UX for free.What we are not doing
parseFile()import path. It already uses a worker and already drivesFeedProgressIndicator. Only minor signature changes (writing JSON blobs instead of CSV blobs).RouteRenderer.createRouteFeatures()orLayerManager.addStopsLayer(). They are slow but happen after the UI is interactive.Phase 1 — Show progress during restore
The user's primary complaint is "the page hangs." Even before we make the restore faster, showing that work is happening reframes the experience entirely. This is the smallest, lowest-risk change and ships value on its own.
Goal:
FeedProgressIndicatoris visible from the momentGTFSEditor.init()runs and updates through restore phases.src/index.ts:127GTFSEditor.init(), callfeedProgressIndicator.startLoading('boot', 'Opening database...')as the very first statement of thetryblock (beforetabLock.init()).await this.gtfsParser.initialize(), callfeedProgressIndicator.updateProgress('boot', 60, 'Restoring patches...').await this.patchManager.initialize(), callfeedProgressIndicator.updateProgress('boot', 80, 'Building map...').await this.mapController.updateMap(), callfeedProgressIndicator.finishLoading('boot').finishLoading('boot')is also called from thecatchblock so a failed init doesn't leave the bar stuck.GTFSParser.restoreDataFromDatabase()(src/modules/gtfs-parser.ts:483), after each table is restored, callfeedProgressIndicator.updateProgress('boot', 5 + (idx / total) * 50, 'Restored ${tableName}...'). The 5–55 % band leaves room for Phases 2–3 to subdivide further.restoreDataFromDatabaseif the table blob is empty/null so the progress bar advances even for absent files.Gotcha:
FeedProgressIndicatoris keyed on operation name;'boot'is a new key — make sure it doesn't collide with the existing'parseFile'key, which can fire concurrently if the user has a URL command (#load=) during refresh.Gotcha:
feedProgressIndicatoris a module-level singleton (src/modules/feed-progress-indicator.ts:105), so it constructs and appends its DOM on first import. That happens duringGTFSEditorconstruction (transitively) — verify the indicator's DOM is in place beforeinit()runs (it should be, but worth aconsole.login the first call).Phase 2 — Drop startup validator
Easiest measurable win.
validateAndUpdateInfo()loops everystop_timesrow, never displays the result, and is called twice (once ininit()and once as a callback passed touiController.initialize).Goal: zero validator work during boot. Validator class stays callable for future on-demand use.
src/index.ts:324, delete thethis.validateAndUpdateInfo();call.src/index.ts:157, thevalidateAndUpdateInfo.bind(this)is passed touiController.initializeas theonValidatecallback. Trace whereuiControllerinvokes it — if it's only edit-driven (not load-driven), leave the callback intact. If it's never used, remove the parameter from theuiController.initializesignature.GTFSEditor.validateAndUpdateInfo()(src/index.ts:365) so manual callers still work, but add a// Not called on startup — invoke manually if the validation panel is opened.comment (the one allowed comment per CLAUDE.md rules: the WHY is non-obvious).uiController.initializeends up not needing the callback, also remove the unusedvalidatorfield if nothing else references it.Gotcha: don't delete the validator module — it's still imported in tests / could be wired up later. Only delete the startup invocation.
Gotcha: search for any other callers of
validateAndUpdateInfoorvalidator.validateFeedto make sure removing the startup call doesn't break a code path that depended on the side-effect of the validator running first (it shouldn't — the validator is pure).Phase 3 — Switch blob format from CSV to JSON, and parse off the main thread
This is the biggest perceived win. CSV blobs require
Papa.parse+processParsedData— the first is single-threaded text parsing, the second is per-row per-field allocation. JSON blobs go through nativeJSON.parse(highly optimized in V8) and the rows come back already type-coerced, soprocessParsedDatadisappears entirely.This phase has two halves: change what we persist, and move the parse into a worker.
3a. Change persisted blob format to JSON
src/modules/gtfs-database.ts, change thefile_blobsstore schema (around line 156 / line 278) from{ tableName, csv }to{ tableName, json }. Update theFileBlobinterface.saveTableBlob(tableName: string, csv: string)(src/modules/gtfs-database.ts:1027) tosaveTableBlob(tableName: string, json: string). Same body, just stores underjsonkey.getTableBlob(tableName: string): Promise<string | null>(src/modules/gtfs-database.ts:1038) to return thejsonfield.src/modules/gtfs-parser.ts:407persistDirtyBlobs(), replaceconst csv = this.generateCSVFromRows(fileName, rows)withconst json = JSON.stringify(rows).src/modules/gtfs-parser.ts:639parseFile()(the new-import path), replaceawait this.gtfsDatabase.saveTableBlob(tableName, fileResult.rawContent)(which writes the raw CSV) withawait this.gtfsDatabase.saveTableBlob(tableName, JSON.stringify(fileResult.data)).GTFSParser.generateCSVFromRows()(src/modules/gtfs-parser.ts:425) andGTFSParser.processParsedData()(src/modules/gtfs-parser.ts:128) andGTFSParser.parseFieldValue()(src/modules/gtfs-parser.ts:64). Search for references first — they're also called fromupdateFileInMemory(src/modules/gtfs-parser.ts:755) andupdateFileContent(src/modules/gtfs-parser.ts:699), which do still need to parse CSV (user pastes CSV into the editor). For those call sites, keepparseFieldValue/processParsedData— only deletegenerateCSVFromRowsif no caller remains. (Revise as needed during implementation; the goal is "no CSV in the blob round-trip" not "no CSV code at all".)src/modules/gtfs-database.ts:213initialize(), bumpthis.dbVersion. The existing upgrade flow already handles wiping + offering export.3b. Move blob restore into a worker
The new-import path already uses a worker (
src/workers/gtfs-parser.worker.ts). Restore is structurally similar (parse strings → emit row arrays) so we add a new message type rather than a new file.src/workers/gtfs-parser.worker.ts, add a new inbound message type{ type: 'restore'; blobs: { tableName: string; json: string }[] }and a corresponding handler.blobs, callsJSON.parse(json)for each, and posts{ type: 'progress', progress, status: 'Restoring ${tableName}...' }between tables, then a final{ type: 'done-restore', tables: { [tableName]: rows[] } }.GTFSParser.restoreDataFromDatabase()(src/modules/gtfs-parser.ts:483), rewrite the body:Promise.alloverALL_GTFS_FILESto fetch allgetTableBlob(...)calls in parallel (independent IDB reads)..geojsonfiles (those still go through their existing path).{ type: 'restore', blobs }, hookonmessageto forward progress tofeedProgressIndicatorand resolve ondone-restore.tablesand for each: setthis.gtfsData[fileName] = { content: '', data: rows, errors: [] }andthis.setupVirtual(tableName, rows). Preserve the shared-array invariant (rowspassed tosetupVirtualmust be the same array stored ingtfsData[fileName].data)..geojsonhandling unchanged — those files are tiny and per-table.Gotcha (critical): the shared-array invariant.
setupVirtual'sflatparameter is stored as a live reference (gtfs-parser.ts:163doc comment). After the worker returns, the deserialized array fromJSON.parseis a fresh array — that's fine, butgtfsData[fileName].dataand theflatpassed tosetupVirtualmust be the same reference, not copies. Mistake would corruptbyIdsilently on the next edit.Gotcha: workers don't share memory; the JSON strings get structured-cloned across the boundary. For very large blobs (100 MB+ JSON for
stop_times) the clone has measurable cost. Mitigate by transferringArrayBuffers instead: store blobs asUint8Arraycontaining UTF-8 JSON bytes, transfer withpostMessage(msg, [buffer]). Defer this optimization — start with plain string transfer, measure, optimize only if string-cloning is a bottleneck.Gotcha: the IDB upgrade modal at
gtfs-database.ts:231shows an "Export & Continue" option which callsexportCurrentBlobsAsZip. If that exporter assumes thecsvfield onfile_blobs, it will break for users upgrading from the old schema. The export reads the old schema before the upgrade fires, so it should still seecsv-keyed records — verify this is true (thepeekVersioncall atgtfs-database.ts:229opens the DB without triggering upgrade). IfexportCurrentBlobsAsZipopens a new DB connection that triggers upgrade-and-wipe before reading, fix it to read the raw blobs via a non-upgrading open.Phase 4 — Defer non-critical post-restore work
After the restore finishes,
init()does a lot of sequential work before the user sees anything:mapController.updateMap(),browseNavigation.refresh(),uiController.updateFileList(). Reordering these to favor first paint reduces perceived load time even when total time is unchanged.Goal: the user sees the breadcrumb / sidebar shell as soon as possible, even if the map is still rendering.
pageStateManager.initializeFromURL()(src/index.ts:330), callbrowseNavigation.refresh()immediately (don't wait for map) anduiController.updateFileList()right after. These are fast and give the user something to look at.mapController.updateMap()in arequestIdleCallback(orrequestAnimationFramefallback) so the browser can paint the sidebar before kicking off the heavy index build.if (this.browseNavigation) { this.browseNavigation.refresh(); }block (src/index.ts:347) — redundant after the reorder.exportBtn.disabled = falseenable (src/index.ts:355) should happen as soon asgtfsParser.initialize()resolves, not at the end ofinit()— the user can export the moment data is in memory.Gotcha:
browseNavigation.refresh()callsgetPageStateManager().getBreadcrumbs()which depends onpageStateManager.initializeFromURL()having run. Order:initializeFromURL→browseNavigation.refresh()→ idle-callback forupdateMap(). Keep that order.Gotcha:
requestIdleCallbackisn't on Safari < 17. Fall back tosetTimeout(fn, 0)if unavailable. Keep it simple — a 4-linerunWhenIdlehelper at the top ofindex.ts.Phase 5 — Eliminate snapshot/blob redundancy
When both a snapshot and a current blob exist,
PatchManager.initialize()discards the blob's data (clearTable+insertRows+setInMemoryFileData) and rebuilds from the snapshot — then replays patches on top. If the blob was already current (i.e. persisted after the last patch), all this work is wasted.Goal: track the version each blob was persisted at; skip snapshot/patch replay when blob is current.
blobVersionfield to themetastore:{ key: 'blobVersion', version: number }. AfterpersistDirtyBlobs()(src/modules/gtfs-parser.ts:407) completes, write the current patch version intometa.blobVersion. ThePatchManagerexposesversionviapatchManager.version(src/modules/patch-manager.ts:413); pass it intopersistDirtyBlobsas an argument so the parser doesn't need a back-reference.PatchManager.initialize()(src/modules/patch-manager.ts:42), after readingcurrentVersion/headVersion, also readmeta.blobVersion. IfblobVersion === currentVersion, skip the entire snapshot-restore + patch-replay block. The in-memory state fromGTFSParser.initialize()is already correct.applyPatchForward(src/modules/patch-manager.ts:92), no changes needed — replay correctness relies on virtual-table dedup which is unchanged.parseFile()(src/modules/gtfs-parser.ts:556— new-import path), after the loop that writes blobs, also writemeta.blobVersion = 0(a fresh import has no patches yet). Otherwise the next refresh will think the blob is stale.initializeEmpty()(src/modules/gtfs-parser.ts:525), setmeta.blobVersion = 0afterpersistDirtyBlobs().Gotcha: there is a 3-second debounce on blob persistence. So a user who edits then immediately refreshes will have
blobVersion < currentVersionand will correctly fall into the snapshot+replay path. The optimization only kicks in when at least 3 seconds have passed since the last edit. That's fine — it's the common case (refresh after the user has been idle).Gotcha: if
persistDirtyBlobswrites partial blobs (some tables inblobDirty, some not),blobVersionshould only advance if every dirty table flushed successfully. Wrap the write + version update in the same operation, and on partial failure leaveblobVersionuntouched so the next load falls back to snapshot+replay (which is correct).Gotcha: snapshots run via
maybeSnapshot()(src/modules/patch-manager.ts:480) everyCONFIG.SNAPSHOT_INTERVAL(50) patches. They are not blob-aware. With this optimization, snapshots are only consulted on the slow path — which is fine; they remain a correctness fallback. No change needed to the snapshot cadence.Phase 6 — Verify and measure
Easy to feel good about a "performance" patch that doesn't measure. Add the timing logs we need to confirm the win on a real large feed.
console.time('[boot] total')at the start ofGTFSEditor.init()andconsole.timeEndat the end (both success and failure paths).console.timearound each major stage:gtfs-parser.initialize,patch-manager.initialize,map-controller.updateMap,browse-navigation.refresh.restoreDataFromDatabase, log per-table parse times for any table over 1 MB JSON.Gotcha: don't leave the
console.timecalls in the shipped code. Either gate them behind aDEBUG_BOOTflag inCONFIGor remove before merging.