AWS May 28, 2026 · 10 min read

Painless serverless: Amplify, Lambda and DynamoDB for real apps

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

When we built the Toyota Colombia admin panel at Destiny, the question was never "serverless or not?" — it was "how do we run a backend nobody has to babysit at 3 a.m.?" The panel fed an offline-first PWA built in Next.js: catalogs, content, data that dealerships pulled from the field. Traffic was spiky, there was no ops team to speak of, and paying for servers running 24/7 to handle occasional bursts made no sense. Serverless fit because of the shape of the problem, not because it was trendy.

Why serverless made sense here

I don't reach for serverless by default. I reach for it when the load pattern is spiky and the team is small. This project was both.

  • Bursty traffic. The panel was used in specific windows: content uploads, catalog updates, the odd query. In between, almost nothing. Paying for an EC2 box to sit idle for that is burning money.
  • No platform team. There were no SREs. With Lambda and DynamoDB I don't patch operating systems, I don't manage scaling, and I don't get paged because a disk filled up.
  • Per-event isolation. Every request is an invocation. A spike doesn't take the whole service down; it scales up and then back to zero.

The price you pay is elsewhere: model DynamoDB well from day one, keep an eye on cold starts, and accept that you're married to AWS. It's worth it if you're honest about those costs.

Modeling DynamoDB: single-table and access patterns

The most common mistake with DynamoDB is treating it like Postgres without joins. It isn't. In DynamoDB you model the access patterns first, and the table second.

For the panel I used a single-table design: one table, generic PK and SK, several entity types living side by side. The patterns we needed were clear: fetch a vehicle by id, list vehicles by category, fetch the content tied to a model.

{
  "PK": "VEHICLE#corolla-2023",
  "SK": "METADATA",
  "type": "vehicle",
  "name": "Corolla",
  "year": 2023,
  "category": "sedan",
  "GSI1PK": "CATEGORY#sedan",
  "GSI1SK": "VEHICLE#corolla-2023"
}

The trick: fetch-by-id goes against PK/SK, and list-by-category goes against a global secondary index (GSI1PK/GSI1SK). No full-table scans. Every read is a Query against a known partition key, which is the only thing DynamoDB does cheaply and predictably.

Rules that saved me pain:

  1. Write down the list of access patterns before touching the table. If a new one shows up later, it's almost always one more GSI, not a redesign.
  2. Prefix your keys (VEHICLE#, CATEGORY#) so different entities coexist without colliding.
  3. Never a Scan on the hot path. If you need to filter by something, that something belongs in a key.

Cold starts: real, but tameable

Cold starts exist and they're annoying, but in 2026 the drama is overblown. In this panel, the backend was Node functions on Lambda behind API Gateway, and the extra latency on the first invocation was never a business problem: this was an internal tool, not a checkout with a millisecond SLA.

That said, here's what I actually do:

  • Keep the bundle small. Fewer dependencies to load means a faster start. Bundling with esbuild and dropping what I don't use matters more than any clever trick.
  • Instantiate the DynamoDB client outside the handler, so the connection is reused across warm invocations.
  • Memory as a CPU lever. On Lambda, CPU scales with allocated memory; bumping from 128 to 512 MB is often cheaper and faster, because the function finishes sooner.
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb";

// Outside the handler: reused across warm invocations.
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export const handler = async (event: { category: string }) => {
  const res = await ddb.send(
    new QueryCommand({
      TableName: process.env.TABLE_NAME,
      IndexName: "GSI1",
      KeyConditionExpression: "GSI1PK = :pk",
      ExpressionAttributeValues: { ":pk": `CATEGORY#${event.category}` },
    }),
  );
  return { items: res.Items ?? [] };
};

💡 With serverless you don't pay for servers — you pay for designing your access patterns right on day one. That design debt doesn't refactor cheaply.

Amplify for the frontend and auth

The panel itself shipped on Amplify: frontend hosting, auth with Cognito underneath, and the glue between the client and the Lambdas. For an internal panel with a login, Amplify saves you weeks of plumbing. You don't stand up the session flow or the CI-backed hosting yourself.

It's convenient, at a price: Amplify is opinionated. The moment you step off its happy path, the abstraction starts to weigh on you, and debugging what it generates underneath gets tedious. For the scope of this project, the trade was a good one.

Infrastructure as code and the ops trade-offs

All of this lives in code. No hand-clicking resources in the console: tables, functions, IAM roles — all declared. Today, with the experience of running JXBS's infra in OpenTofu (ECS Fargate, queues, Secrets Manager, CI/CD), I'm even more convinced that the worst mistake is having resources nobody knows the origin of. If it isn't in code, it doesn't exist.

The upside of this model, no dressing it up:

  • No servers to patch or maintain.
  • Scale to zero: no traffic, no compute bill.
  • A smaller ops surface for a small team.

The uncomfortable part, with the same honesty:

  • Real lock-in. DynamoDB and Lambda don't migrate to another cloud without a rewrite.
  • Debugging is different. There's no server to SSH into; you live in logs and traces.
  • Cost can surprise you if you Scan or model badly: DynamoDB is dirt cheap used well and brutal used badly.

What I'd do differently today (2026)

I'd pick serverless again for this case; the shape of the problem hasn't changed. But I'd change a few things:

  • Less Amplify, more explicit control. Today I'd wire up hosting and auth more directly and keep Amplify for prototypes, not for something that has to live for years.
  • Start in OpenTofu, not the proprietary tool. It's what I run in production now, and I wouldn't go back.
  • Invest in observability earlier. Distributed tracing from day one; in serverless, not having it is flying blind.

Painless serverless doesn't mean serverless without decisions. It means making the hard decisions up front — data modeling, IaC, observability — so you're not paying for them at 3 a.m. later.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

IA

AI-built startups in 2026: the real numbers behind the hype

IA

How to survive as a programmer in the AI era (without turning cynical or naive)