Skip to content

fix: add pagination to GET /api/workflows to prevent OOM on large workspaces#3578

Open
guoyangzhen wants to merge 2 commits intosimstudioai:mainfrom
guoyangzhen:fix/workflow-pagination
Open

fix: add pagination to GET /api/workflows to prevent OOM on large workspaces#3578
guoyangzhen wants to merge 2 commits intosimstudioai:mainfrom
guoyangzhen:fix/workflow-pagination

Conversation

@guoyangzhen
Copy link

Problem

The GET /api/workflows endpoint returned ALL workflows without any limit, causing Node.js OOM crashes when workspaces have thousands of workflows. Also caused browser freezes from parsing massive JSON payloads.

Fix

  • Add limit query param (default 100, max 500)
  • Add cursor query param for future pagination support
  • Fetch limit+1 to detect if more pages exist
  • Return nextCursor in response when more results available

Backward Compatibility

  • Response format unchanged for workspaces with <100 workflows
  • data array still returned, nextCursor is additive
  • Existing callers work without changes

Fixes #3435

@cursor
Copy link

cursor bot commented Mar 14, 2026

PR Summary

Medium Risk
Changes the GET /api/workflows response behavior by limiting results by default and adding cursor-based pagination, which could impact callers that implicitly relied on receiving all workflows in one request.

Overview
Prevents large-workspace OOMs by adding keyset pagination to GET /api/workflows.

The endpoint now accepts limit (default 100, max 500) and cursor, fetches limit+1 rows to detect additional pages, and returns { data, nextCursor } (with nextCursor computed from the last item’s sortOrder/createdAt/id).

Written by Cursor Bugbot for commit 3a453a2. This will update automatically on new commits. Configure here.

@vercel
Copy link

vercel bot commented Mar 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Mar 14, 2026 1:01pm

Request Review

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 14, 2026

Greptile Summary

This PR adds limit-based pagination to GET /api/workflows to prevent OOM crashes on large workspaces. The core mechanism — fetching limit+1 rows to detect a next page — is sound, but the cursor implementation is incomplete and contains two blocking bugs that leave actual pagination broken.

Key issues found:

  • Cursor is never applied to the DB querycursor is parsed from the query string but no WHERE clause filtering is added to either the workspaceId branch or the inArray branch. Any caller passing ?cursor=<id> receives the same first page every time; the feature is entirely non-functional.
  • gt imported but unused — The gt operator from drizzle-orm was imported in anticipation of the cursor WHERE clause but never used, which will cause a lint/compile error.
  • Cursor is incompatible with the sort ordernextCursor is set to workflow.id, but queries are ordered by [sortOrder, createdAt, id]. Applying gt(workflow.id, cursor) as a WHERE filter would skip or duplicate rows in pages 2+ because workflows with lower sortOrder/createdAt but higher id than the cursor would be incorrectly excluded. A correct keyset cursor must encode all three sort columns.

Confidence Score: 2/5

  • The limit cap prevents OOM but cursor pagination is non-functional and the unused gt import will cause lint failures.
  • The limit+1 guard does successfully cap query size which addresses the OOM for the common case. However the cursor parameter is read but never applied, making multi-page traversal impossible and returning stale first-page results to any caller who tries to paginate. The gt unused import will fail lint checks. The cursor design is also architecturally incorrect for the compound sort key in use.
  • apps/sim/app/api/workflows/route.ts — cursor logic is incomplete and incorrect

Important Files Changed

Filename Overview
apps/sim/app/api/workflows/route.ts Adds limit/cursor query params and limit+1 trick to detect next page, but the cursor variable is never applied to either DB query (always returns page 1), gt is imported but unused, and the cursor value is based solely on workflow.id while ordering uses a three-column compound key — making true keyset pagination incorrect once the WHERE clause is added.

Sequence Diagram

sequenceDiagram
    participant Client
    participant GET /api/workflows
    participant DB

    Client->>GET /api/workflows: GET ?workspaceId=X&limit=100&cursor=<id>
    Note over GET /api/workflows: Parses limit (clamped 1–500)<br/>Parses cursor (read but UNUSED)
    GET /api/workflows->>DB: SELECT * FROM workflow<br/>WHERE workspaceId = X<br/>ORDER BY sortOrder, createdAt, id<br/>LIMIT 101
    DB-->>GET /api/workflows: Up to 101 rows
    Note over GET /api/workflows: hasMore = rows.length > 100<br/>data = rows.slice(0, 100)<br/>nextCursor = data[last].id (if hasMore)
    GET /api/workflows-->>Client: { data: [...], nextCursor: <id> | null }
    Note over Client: Client sends next request with cursor=<id><br/>but server ignores cursor — returns page 1 again
Loading

Last reviewed commit: cb99aa7

const startTime = Date.now()
const url = new URL(request.url)
const workspaceId = url.searchParams.get('workspaceId')
const cursor = url.searchParams.get('cursor')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cursor parsed but never applied to the query

The cursor variable is read from the query params but is never used in either of the two database queries. As a result, passing ?cursor=<id> is silently ignored and the server always returns the first page — making cursor-based pagination completely non-functional for callers who attempt to use it.

The gt operator imported on line 4 was presumably intended for a WHERE clause like .where(and(...existingWhere..., gt(workflow.id, cursor))), but that call was never added. Since gt is also unused, this will trigger a lint/TypeScript error.

To properly implement cursor-based offset, the WHERE clause needs to be extended, e.g.:

// In the workspaceId branch
.where(
  cursor
    ? and(eq(workflow.workspaceId, workspaceId), gt(workflow.id, cursor))
    : eq(workflow.workspaceId, workspaceId)
)

Note also that using gt(workflow.id, cursor) only gives correct pagination when ordering solely by id. With the current compound ordering [asc(sortOrder), asc(createdAt), asc(id)], a pure id > cursor filter will skip or repeat rows where sortOrder or createdAt differs between pages. A correct keyset cursor needs to encode all three sort columns.

import { permissions, workflow, workflowFolder } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'
import { and, asc, eq, gt, inArray, isNull, min } from 'drizzle-orm'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused gt import

gt is imported from drizzle-orm but is never referenced in the file. This will produce a lint/compile warning and should be removed until the cursor WHERE clause is actually implemented.

Suggested change
import { and, asc, eq, gt, inArray, isNull, min } from 'drizzle-orm'
import { and, asc, eq, inArray, isNull, min } from 'drizzle-orm'

// Determine if there are more results and set cursor
const hasMore = workflows.length > limit
const data = hasMore ? workflows.slice(0, limit) : workflows
const nextCursor = hasMore ? data[data.length - 1]?.id : null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor value does not match sort order — pagination will produce incorrect results

The nextCursor is set to the last item's id:

const nextCursor = hasMore ? data[data.length - 1]?.id : null

But the query is ordered by [asc(sortOrder), asc(createdAt), asc(id)]. When the cursor is eventually applied as gt(workflow.id, cursor), this filter is only semantically correct when ordering by id alone. In the current compound ordering, rows with a smaller sortOrder but a larger id than the cursor will be excluded from page 2 even though they should appear, and vice-versa.

To avoid skipping or duplicating results, the cursor must encode the full sort key (e.g., sortOrder, createdAt, and id combined — typically as a base64-encoded JSON or composite string), and the WHERE clause must replicate the tie-breaking logic of the ORDER BY.

- Parse base64 cursor containing {sortOrder, createdAt, id}
- Build keyset pagination condition for composite ORDER BY
- Encode nextCursor as composite value matching sort order
- Remove unused 'gt' import
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

const workspaceId = url.searchParams.get('workspaceId')
const cursor = url.searchParams.get('cursor')
const limitParam = url.searchParams.get('limit')
const limit = Math.min(Math.max(parseInt(limitParam || '100', 10) || 100, 1), 500)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Silent data truncation for callers expecting all workflows

High Severity

The new default limit of 100 silently truncates results for all existing callers, none of which handle nextCursor pagination. fetchWorkflows in workflows.ts only loads the first 100 workflows into the sidebar registry. use-export-workspace.ts only exports the first 100 workflows, producing silently incomplete ZIP backups. fetchDeployedWorkflows in workflow-mcp-servers.ts may miss deployed workflows. These are the exact large-workspace scenarios this PR targets.

Fix in Cursor Fix in Web

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Missing pagination on GET /api/workflows causes memory exhaustion / DoS risk on large workspaces

1 participant