Skip to content

Opaque Cursors with DynamoDB

Posted on:December 23, 2023 at 02:37 AM

I’m pretty sure I found this somewhere, but decided to document here for myself and others who find it useful.

In an attempt to keep the implementation of DynamoDB pagination transparent to an API, I wanted to obfuscate the usage of item keys and use just an opaque string cursor that can be passed around. This is what that looks like:

import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";
import type { QueryCommandInput } from "@aws-sdk/client-dynamodb";
import { QueryCommand } from "@aws-sdk/client-dynamodb";

export const getEvents = async (storeId: string, cursor?: string) => {
  const startKey = cursor
    ? marshall(JSON.parse(Buffer.from(cursor, "base64").toString("ascii")))
    : undefined;

  const queryCommandInput: QueryCommandInput = {
    TableName: "events",
    KeyConditionExpression: "#pk = :pk",
    ExpressionAttributeNames: {
      "#pk": "pk",
    ExpressionAttributeValues: marshall({
      ":pk": `STORE#${storeId}`,
    ExclusiveStartKey: startKey,
  const queryCommand = new QueryCommand(queryCommandInput);
  const queryResult = await dynamodb.send(queryCommand);
  const resultItems = queryResult.Items
    ? => (unmarshall(result) as EventRecord).data)
    : [];

  const lastKey = queryResult.LastEvaluatedKey
    ? Buffer.from(
    : null;

  return {
    cursor: lastKey,
    events: resultItems,

The crucial components are the first bit and the last bit of this function. To get an opaque cursor, we unmarshal the last evaluated key in the response from Dynamo, JSON.stringify it, and encode it into Base64.

On the flip side, when given a cursor to us as the start key for another query, we Base64 decode it, JSON.parse it, and marshal that key.

Simple as that.